Add calendar app — shared events calendar with departments, staff tagging, bank holidays and two-way phone sync
Fastify/Postgres backend + React frontend matching the stack's app conventions, plus a hand-rolled RFC 4791 CalDAV server (caldav-adapter turned out Koa-only in practice) so calendars subscribe as genuine two-way sync in Apple/Google/Outlook. v1 scope: multiple colour-coded calendars, department/staff event tagging via live Workforce lookups, month/week/day/list views, dashboard, file attachments, activity log, and an auto-synced UK bank holidays calendar. Verified end-to-end locally against real Postgres: REST CRUD, CalDAV discovery/PROPFIND/REPORT/PUT/sync-collection, all-day date handling, system-calendar write protection, and activity logging across both the web and CalDAV write paths. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
bf5557d277
53 changed files with 12529 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
uploads/
|
||||||
|
*.log
|
||||||
8
backend/Dockerfile
Normal file
8
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
COPY src ./src
|
||||||
|
RUN mkdir -p /app/uploads
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "src/index.js"]
|
||||||
1507
backend/package-lock.json
generated
Normal file
1507
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
backend/package.json
Normal file
22
backend/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "hnf-calendar-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",
|
||||||
|
"@xmldom/xmldom": "^0.8.10",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"fastify": "^4.28.1",
|
||||||
|
"ical.js": "^2.1.0",
|
||||||
|
"ics": "^3.8.1",
|
||||||
|
"jose": "^5.9.6",
|
||||||
|
"pg": "^8.13.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
57
backend/src/auth.js
Normal file
57
backend/src/auth.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { jwtVerify } from 'jose'
|
||||||
|
import { isOnsite } from './ip-check.js'
|
||||||
|
|
||||||
|
const APP_SLUG = process.env.APP_SLUG || 'calendar'
|
||||||
|
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 all non-settings caps until re-login
|
||||||
|
caps = ['view', 'guest_details', 'rate_details', 'view_all_notes', 'complete_tasks', 'update_status']
|
||||||
|
}
|
||||||
|
|
||||||
|
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}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
backend/src/db.js
Normal file
106
backend/src/db.js
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
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 calendars (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL,
|
||||||
|
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_by TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
calendar_id INT NOT NULL REFERENCES calendars(id),
|
||||||
|
uid TEXT NOT NULL UNIQUE, -- iCalendar UID, e.g. '<uuid>@calendar.hotelnumberfour.com'
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
location TEXT,
|
||||||
|
start_at TIMESTAMPTZ NOT NULL,
|
||||||
|
end_at TIMESTAMPTZ NOT NULL,
|
||||||
|
all_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
sequence INT NOT NULL DEFAULT 0,
|
||||||
|
created_by TEXT, -- email
|
||||||
|
created_by_name TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS events_calendar_idx ON events (calendar_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS events_start_idx ON events (start_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS events_deleted_idx ON events (deleted_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS event_departments (
|
||||||
|
event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
department_id TEXT NOT NULL,
|
||||||
|
department_name TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (event_id, department_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS event_assignees (
|
||||||
|
event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
user_email TEXT NOT NULL,
|
||||||
|
user_name TEXT,
|
||||||
|
PRIMARY KEY (event_id, user_email)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS event_attachments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
stored_path TEXT NOT NULL, -- relative path under uploads dir
|
||||||
|
mime_type TEXT,
|
||||||
|
size_bytes INT,
|
||||||
|
uploaded_by TEXT, -- email
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS caldav_credentials (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_email TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL UNIQUE, -- generated, e.g. '<localpart>-<random6>'
|
||||||
|
password_hash TEXT NOT NULL, -- bcrypt
|
||||||
|
label TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_used_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS activity_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
actor_email TEXT,
|
||||||
|
actor_name TEXT,
|
||||||
|
action TEXT NOT NULL, -- created | updated | deleted
|
||||||
|
entity_type TEXT NOT NULL, -- event | calendar | attachment | caldav_credential
|
||||||
|
entity_id INT,
|
||||||
|
calendar_id INT,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
details JSONB,
|
||||||
|
source TEXT NOT NULL DEFAULT 'web', -- web | caldav
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS activity_log_calendar_idx ON activity_log (calendar_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS activity_log_entity_idx ON activity_log (entity_type, entity_id);
|
||||||
|
`)
|
||||||
|
|
||||||
|
await seedDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedDefaults() {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO calendars (slug, name, color, is_system)
|
||||||
|
VALUES ('bank-holidays', 'Bank Holidays', '#64748b', TRUE)
|
||||||
|
ON CONFLICT (slug) DO NOTHING`
|
||||||
|
)
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO calendars (slug, name, color, is_system)
|
||||||
|
VALUES ('general', 'All Staff', '#c9a84c', FALSE)
|
||||||
|
ON CONFLICT (slug) DO NOTHING`
|
||||||
|
)
|
||||||
|
}
|
||||||
56
backend/src/index.js
Normal file
56
backend/src/index.js
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
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 { startBankHolidaySync } from './lib/bank-holidays.js'
|
||||||
|
import { calendarRoutes } from './routes/calendars.js'
|
||||||
|
import { eventRoutes } from './routes/events.js'
|
||||||
|
import { attachmentRoutes } from './routes/attachments.js'
|
||||||
|
import { activityRoutes } from './routes/activity.js'
|
||||||
|
import { departmentRoutes } from './routes/departments.js'
|
||||||
|
import { caldavCredentialRoutes } from './routes/caldav-credentials.js'
|
||||||
|
import { caldavRoutes } from './routes/caldav.js'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const UPLOADS_DIR = join(__dirname, '..', 'uploads')
|
||||||
|
|
||||||
|
// ignoreTrailingSlash matters for the hand-rolled CalDAV routes — clients
|
||||||
|
// vary on whether they request collection URLs with or without a trailing
|
||||||
|
// slash, and RFC 4918 treats them as the same resource.
|
||||||
|
const app = Fastify({ logger: true, trustProxy: true, ignoreTrailingSlash: true })
|
||||||
|
const startedAt = Date.now()
|
||||||
|
|
||||||
|
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', version: process.env.BUILD_VERSION || String(startedAt) }))
|
||||||
|
|
||||||
|
await app.register(calendarRoutes)
|
||||||
|
await app.register(eventRoutes)
|
||||||
|
await app.register(attachmentRoutes, { uploadsDir: UPLOADS_DIR })
|
||||||
|
await app.register(activityRoutes)
|
||||||
|
await app.register(departmentRoutes)
|
||||||
|
await app.register(caldavCredentialRoutes)
|
||||||
|
await app.register(caldavRoutes)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initDb()
|
||||||
|
startBankHolidaySync(app)
|
||||||
|
await app.listen({ port: 3001, host: '0.0.0.0' })
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
80
backend/src/ip-check.js
Normal file
80
backend/src/ip-check.js
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import dns from 'dns/promises'
|
||||||
|
|
||||||
|
const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim()
|
||||||
|
const matchers = raw.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
|
||||||
|
const TTL = 5 * 60 * 1000
|
||||||
|
const cache = new Map()
|
||||||
|
|
||||||
|
const PUBLIC_IP_URLS = [
|
||||||
|
'https://api.ipify.org',
|
||||||
|
'https://ifconfig.co/ip',
|
||||||
|
'https://icanhazip.com',
|
||||||
|
]
|
||||||
|
|
||||||
|
function normalizeIP(ip) {
|
||||||
|
return ip?.startsWith('::ffff:') ? ip.slice(7) : ip
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIPv4(s) {
|
||||||
|
return /^\d{1,3}(\.\d{1,3}){3}$/.test(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipInCidr(ip, cidr) {
|
||||||
|
const [range, bits] = cidr.split('/')
|
||||||
|
if (!isIPv4(ip) || !isIPv4(range)) return false
|
||||||
|
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
|
||||||
|
const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0
|
||||||
|
return (toInt(ip) & mask) === (toInt(range) & mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPublicIP() {
|
||||||
|
for (const url of PUBLIC_IP_URLS) {
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), 4000)
|
||||||
|
const res = await fetch(url, { signal: ctrl.signal })
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (!res.ok) continue
|
||||||
|
const ip = (await res.text()).trim()
|
||||||
|
if (isIPv4(ip)) return ip
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDynamic(key, resolver) {
|
||||||
|
const hit = cache.get(key)
|
||||||
|
if (hit && Date.now() < hit.expiry) return hit.ip
|
||||||
|
const ip = await resolver()
|
||||||
|
if (ip) {
|
||||||
|
cache.set(key, { ip, expiry: Date.now() + TTL })
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
return hit ? hit.ip : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isOnsite(requestIP) {
|
||||||
|
if (matchers.length === 0 || matchers.includes('disabled')) return true
|
||||||
|
const ip = normalizeIP(requestIP)
|
||||||
|
if (!ip) return false
|
||||||
|
|
||||||
|
for (const m of matchers) {
|
||||||
|
if (m === 'auto') {
|
||||||
|
const pub = await resolveDynamic('auto', fetchPublicIP)
|
||||||
|
if (pub && ip === pub) return true
|
||||||
|
} else if (m.includes('/')) {
|
||||||
|
if (ipInCidr(ip, m)) return true
|
||||||
|
} else if (/[a-zA-Z]/.test(m)) {
|
||||||
|
const resolved = await resolveDynamic(m, async () => {
|
||||||
|
try { return (await dns.resolve4(m))[0] } catch { return null }
|
||||||
|
})
|
||||||
|
if (resolved && ip === resolved) return true
|
||||||
|
} else {
|
||||||
|
if (ip === m) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
31
backend/src/lib/activity.js
Normal file
31
backend/src/lib/activity.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
// Single write path for the activity_log audit trail. Called from every
|
||||||
|
// mutation in routes/calendars.js, routes/events.js, routes/attachments.js,
|
||||||
|
// routes/caldav-credentials.js, and from lib/caldav-store.js (source: 'caldav').
|
||||||
|
export async function logActivity(pool, {
|
||||||
|
actorEmail = null,
|
||||||
|
actorName = null,
|
||||||
|
action,
|
||||||
|
entityType,
|
||||||
|
entityId = null,
|
||||||
|
calendarId = null,
|
||||||
|
summary,
|
||||||
|
details = null,
|
||||||
|
source = 'web',
|
||||||
|
}) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO activity_log
|
||||||
|
(actor_email, actor_name, action, entity_type, entity_id, calendar_id, summary, details, source)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||||
|
[
|
||||||
|
actorEmail,
|
||||||
|
actorName,
|
||||||
|
action,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
calendarId,
|
||||||
|
summary,
|
||||||
|
details != null ? JSON.stringify(details) : null,
|
||||||
|
source,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
57
backend/src/lib/bank-holidays.js
Normal file
57
backend/src/lib/bank-holidays.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
// Syncs England & Wales bank holidays from gov.uk into the system
|
||||||
|
// "Bank Holidays" calendar. Idempotent (uid is stable per date) and
|
||||||
|
// tolerant of the upstream API being unreachable — never crashes the app.
|
||||||
|
import { pool } from '../db.js'
|
||||||
|
|
||||||
|
const SOURCE_URL = 'https://www.gov.uk/bank-holidays.json'
|
||||||
|
const DIVISION = 'england-and-wales'
|
||||||
|
|
||||||
|
export async function syncBankHolidays(pool) {
|
||||||
|
let data
|
||||||
|
try {
|
||||||
|
const res = await fetch(SOURCE_URL, { signal: AbortSignal.timeout(10000) })
|
||||||
|
if (!res.ok) throw new Error(`gov.uk bank holidays fetch failed: ${res.status}`)
|
||||||
|
data = await res.json()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bank-holidays] sync failed, skipping:', err.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = data?.[DIVISION]?.events
|
||||||
|
if (!Array.isArray(events)) {
|
||||||
|
console.error('[bank-holidays] unexpected response shape, skipping')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: cal } = await pool.query(`SELECT id FROM calendars WHERE slug = 'bank-holidays'`)
|
||||||
|
if (!cal.length) {
|
||||||
|
console.error('[bank-holidays] system calendar not found (seedDefaults should have created it), skipping')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const calendarId = cal[0].id
|
||||||
|
|
||||||
|
for (const holiday of events) {
|
||||||
|
const uid = `bank-holiday-${holiday.date}`
|
||||||
|
const startAt = `${holiday.date}T00:00:00Z`
|
||||||
|
const endAt = `${holiday.date}T00:00:00Z`
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO events
|
||||||
|
(calendar_id, uid, title, description, all_day, start_at, end_at, created_by_name, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, TRUE, $5, $6, 'GOV.UK Bank Holidays', NOW())
|
||||||
|
ON CONFLICT (uid) DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
start_at = EXCLUDED.start_at,
|
||||||
|
end_at = EXCLUDED.end_at,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[calendarId, uid, holiday.title, holiday.notes || null, startAt, endAt]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[bank-holidays] synced ${events.length} events`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startBankHolidaySync(app) {
|
||||||
|
syncBankHolidays(pool).catch(err => app.log.error(err))
|
||||||
|
setInterval(() => syncBankHolidays(pool).catch(err => app.log.error(err)), 24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
200
backend/src/lib/caldav-store.js
Normal file
200
backend/src/lib/caldav-store.js
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
// Data-access + iCalendar translation layer for the hand-rolled CalDAV
|
||||||
|
// server in routes/caldav.js. All event mutations funnel through
|
||||||
|
// routes/events.js's createEvent/updateEvent/deleteEvent/getEventDetail so
|
||||||
|
// web and CalDAV writes share one source of truth (source: 'caldav' here).
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import ICAL from 'ical.js'
|
||||||
|
import { createEvent as buildIcsEvent } from 'ics'
|
||||||
|
import { createEvent, updateEvent, deleteEvent } from '../routes/events.js'
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
// ---- auth -------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function authenticateBasic(pool, username, password) {
|
||||||
|
if (!username || !password) return null
|
||||||
|
const { rows } = await pool.query('SELECT * FROM caldav_credentials WHERE username = $1', [username])
|
||||||
|
if (!rows.length) return null
|
||||||
|
const cred = rows[0]
|
||||||
|
const ok = await bcrypt.compare(password, cred.password_hash)
|
||||||
|
if (!ok) return null
|
||||||
|
pool.query('UPDATE caldav_credentials SET last_used_at = NOW() WHERE id = $1', [cred.id]).catch(() => {})
|
||||||
|
return { email: cred.user_email }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- calendar collections ----------------------------------------------
|
||||||
|
|
||||||
|
export async function listCalendars(pool) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT * FROM calendars WHERE deleted_at IS NULL ORDER BY is_system ASC, name ASC`
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCalendarBySlug(pool, slug) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT * FROM calendars WHERE slug = $1 AND deleted_at IS NULL`, [slug]
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highest-water-mark timestamp for a calendar's contents — used as the
|
||||||
|
// CalendarServer getctag so clients know when to re-list without diffing
|
||||||
|
// every resource's etag.
|
||||||
|
export async function getCalendarCtag(pool, calendarId) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT GREATEST(
|
||||||
|
COALESCE((SELECT MAX(updated_at) FROM events WHERE calendar_id = $1 AND deleted_at IS NULL), 'epoch'),
|
||||||
|
COALESCE((SELECT MAX(deleted_at) FROM events WHERE calendar_id = $1 AND deleted_at IS NOT NULL), 'epoch')
|
||||||
|
) AS ts`,
|
||||||
|
[calendarId]
|
||||||
|
)
|
||||||
|
const ts = rows[0]?.ts
|
||||||
|
return ts ? new Date(ts).getTime().toString() : '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEventsInCalendar(pool, calendarId) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, uid, sequence, updated_at FROM events WHERE calendar_id = $1 AND deleted_at IS NULL ORDER BY uid`,
|
||||||
|
[calendarId]
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFullEvent(pool, calendarId, uid) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT * FROM events WHERE calendar_id = $1 AND uid = $2 AND deleted_at IS NULL`,
|
||||||
|
[calendarId, uid]
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events changed (created/updated) or removed (soft-deleted) since `since`
|
||||||
|
// (a Date, or null for a full initial sync). Minimal RFC 6578 support.
|
||||||
|
export async function getChangesSince(pool, calendarId, since) {
|
||||||
|
const params = [calendarId]
|
||||||
|
let sinceClause = 'TRUE'
|
||||||
|
if (since) {
|
||||||
|
params.push(since)
|
||||||
|
sinceClause = `updated_at > $2`
|
||||||
|
}
|
||||||
|
const { rows: changed } = await pool.query(
|
||||||
|
`SELECT id, uid, sequence, updated_at FROM events WHERE calendar_id = $1 AND deleted_at IS NULL AND ${sinceClause} ORDER BY updated_at`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
let removed = []
|
||||||
|
if (since) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT uid FROM events WHERE calendar_id = $1 AND deleted_at IS NOT NULL AND deleted_at > $2`,
|
||||||
|
[calendarId, since]
|
||||||
|
)
|
||||||
|
removed = rows
|
||||||
|
}
|
||||||
|
return { changed, removed }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- etags --------------------------------------------------------------
|
||||||
|
|
||||||
|
export function etagFor(event) {
|
||||||
|
return `"${event.sequence}-${new Date(event.updated_at).getTime()}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- iCalendar <-> our event shape ---------------------------------------
|
||||||
|
|
||||||
|
function toDateArray(d) {
|
||||||
|
const dt = new Date(d)
|
||||||
|
return [dt.getUTCFullYear(), dt.getUTCMonth() + 1, dt.getUTCDate()]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateTimeArray(d) {
|
||||||
|
const dt = new Date(d)
|
||||||
|
return [dt.getUTCFullYear(), dt.getUTCMonth() + 1, dt.getUTCDate(), dt.getUTCHours(), dt.getUTCMinutes()]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generates a full VCALENDAR document containing one VEVENT. Calendar name
|
||||||
|
// is mapped one-way into CATEGORIES for context; it does not round-trip.
|
||||||
|
export function eventToIcsString(event, calendarName) {
|
||||||
|
const attrs = {
|
||||||
|
uid: event.uid,
|
||||||
|
title: event.title,
|
||||||
|
description: event.description || undefined,
|
||||||
|
location: event.location || undefined,
|
||||||
|
sequence: event.sequence,
|
||||||
|
categories: calendarName ? [calendarName] : undefined,
|
||||||
|
calName: calendarName || undefined,
|
||||||
|
startInputType: 'utc',
|
||||||
|
startOutputType: 'utc',
|
||||||
|
endInputType: 'utc',
|
||||||
|
endOutputType: 'utc',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.all_day) {
|
||||||
|
// Our end_at is the inclusive last day; RFC 5545 requires DTEND for a
|
||||||
|
// DATE value to be the day *after* the last day (exclusive).
|
||||||
|
attrs.start = toDateArray(event.start_at)
|
||||||
|
attrs.end = toDateArray(new Date(new Date(event.end_at).getTime() + DAY_MS))
|
||||||
|
} else {
|
||||||
|
attrs.start = toDateTimeArray(event.start_at)
|
||||||
|
attrs.end = toDateTimeArray(event.end_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error, value } = buildIcsEvent(attrs)
|
||||||
|
if (error) throw error
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses an incoming PUT body (a VCALENDAR with one VEVENT) into our event
|
||||||
|
// field shape. RRULE, if present, is ignored — v1 has no recurring events;
|
||||||
|
// every VEVENT is stored as a single one-off occurrence.
|
||||||
|
export function parseIcsEvent(icsBody) {
|
||||||
|
const jcal = ICAL.parse(icsBody)
|
||||||
|
const comp = new ICAL.Component(jcal)
|
||||||
|
const vevent = comp.getFirstSubcomponent('vevent')
|
||||||
|
if (!vevent) throw new Error('No VEVENT found in submitted iCalendar data')
|
||||||
|
const event = new ICAL.Event(vevent)
|
||||||
|
|
||||||
|
if (!event.uid) throw new Error('VEVENT missing UID')
|
||||||
|
if (!event.startDate) throw new Error('VEVENT missing DTSTART')
|
||||||
|
|
||||||
|
const allDay = event.startDate.isDate === true
|
||||||
|
const start = event.startDate.toJSDate()
|
||||||
|
let end
|
||||||
|
if (event.endDate) {
|
||||||
|
end = event.endDate.toJSDate()
|
||||||
|
} else if (event.duration) {
|
||||||
|
end = new Date(start.getTime() + event.duration.toSeconds() * 1000)
|
||||||
|
} else {
|
||||||
|
end = start
|
||||||
|
}
|
||||||
|
if (allDay) {
|
||||||
|
// Convert the RFC-mandated exclusive DTEND back to our inclusive
|
||||||
|
// last-day storage convention.
|
||||||
|
end = new Date(end.getTime() - DAY_MS)
|
||||||
|
if (end.getTime() < start.getTime()) end = start
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uid: event.uid,
|
||||||
|
title: event.summary || '(untitled)',
|
||||||
|
description: event.description || null,
|
||||||
|
location: event.location || null,
|
||||||
|
all_day: allDay,
|
||||||
|
start_at: start.toISOString(),
|
||||||
|
end_at: end.toISOString(),
|
||||||
|
sequence: Number.isFinite(event.sequence) ? event.sequence : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- mutation pass-through (source: 'caldav') ----------------------------
|
||||||
|
|
||||||
|
export async function createEventFromCaldav(pool, calendarId, parsed, actorEmail) {
|
||||||
|
return createEvent(pool, { calendar_id: calendarId, ...parsed }, { email: actorEmail, name: actorEmail }, 'caldav')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEventFromCaldav(pool, eventId, parsed, actorEmail) {
|
||||||
|
return updateEvent(pool, eventId, parsed, { email: actorEmail, name: actorEmail }, 'caldav')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEventFromCaldav(pool, eventId, actorEmail) {
|
||||||
|
return deleteEvent(pool, eventId, { email: actorEmail, name: actorEmail }, 'caldav')
|
||||||
|
}
|
||||||
92
backend/src/lib/workforce.js
Normal file
92
backend/src/lib/workforce.js
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
// Workforce integration — department lookups for event tagging and the
|
||||||
|
// "mine" event filter. Ported from auth/src/workforce.js (not wages' version,
|
||||||
|
// which is wage-sync-specific). Never let a Workforce API hiccup break the
|
||||||
|
// calendar app: callers that can degrade gracefully should catch and fall
|
||||||
|
// back to an empty list.
|
||||||
|
const SETTINGS_URL = process.env.SETTINGS_URL || 'http://10.10.10.106:3080'
|
||||||
|
const SETTINGS_SECRET = process.env.SETTINGS_SECRET || ''
|
||||||
|
|
||||||
|
let _credsCache = null // { creds, expires_at }
|
||||||
|
let _deptsCache = null // { depts, expires_at } — separate 5-min cache, hit on every tagging picker load
|
||||||
|
|
||||||
|
export async function getWorkforceCreds() {
|
||||||
|
if (_credsCache && Date.now() < _credsCache.expires_at) return _credsCache.creds
|
||||||
|
const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/workforce`, {
|
||||||
|
headers: { Authorization: `Bearer ${SETTINGS_SECRET}` },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch Workforce credentials from settings: ${res.status}`)
|
||||||
|
const creds = await res.json()
|
||||||
|
if (!creds.bearer_token) throw new Error('Workforce bearer token not configured in settings')
|
||||||
|
_credsCache = { creds, expires_at: Date.now() + 5 * 60_000 }
|
||||||
|
return creds
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wfFetch(path) {
|
||||||
|
const creds = await getWorkforceCreds()
|
||||||
|
const baseUrl = creds.base_url || 'https://my.workforce.com'
|
||||||
|
const res = await fetch(`${baseUrl}${path}`, {
|
||||||
|
headers: { Authorization: `Bearer ${creds.bearer_token}` },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '')
|
||||||
|
throw new Error(`Workforce API error ${res.status} at ${path}${body ? ': ' + body.slice(0, 200) : ''}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wfFetchPaged(path) {
|
||||||
|
const results = []
|
||||||
|
let page = 1
|
||||||
|
while (true) {
|
||||||
|
const sep = path.includes('?') ? '&' : '?'
|
||||||
|
const data = await wfFetch(`${path}${sep}page=${page}&page_size=100`)
|
||||||
|
const items = Array.isArray(data) ? data : (data.users ?? data.departments ?? data.teams ?? data.locations ?? [])
|
||||||
|
results.push(...items)
|
||||||
|
if (items.length < 100) break
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findEmployeeByEmail(email) {
|
||||||
|
const data = await wfFetch(`/api/v2/users?email=${encodeURIComponent(email)}`)
|
||||||
|
const users = Array.isArray(data) ? data : (data.users ?? [])
|
||||||
|
return users.length > 0 ? users[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllDepartments() {
|
||||||
|
if (_deptsCache && Date.now() < _deptsCache.expires_at) return _deptsCache.depts
|
||||||
|
const depts = await wfFetchPaged('/api/v2/departments')
|
||||||
|
_deptsCache = { depts, expires_at: Date.now() + 5 * 60_000 }
|
||||||
|
return depts
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEmployeeDepartments(wfUserId) {
|
||||||
|
const depts = await getAllDepartments()
|
||||||
|
const id = String(wfUserId)
|
||||||
|
return depts.filter(d => {
|
||||||
|
const staff = (d.staff ?? []).map(String)
|
||||||
|
const managers = (d.managers ?? []).map(String)
|
||||||
|
return staff.includes(id) || managers.includes(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience: email -> Workforce departments, [] on any failure (unknown
|
||||||
|
// employee, Workforce not configured, network error) so callers never need
|
||||||
|
// their own try/catch.
|
||||||
|
export async function getDepartmentsForEmail(email) {
|
||||||
|
try {
|
||||||
|
const employee = await findEmployeeByEmail(email)
|
||||||
|
if (!employee?.id) return []
|
||||||
|
return await getEmployeeDepartments(employee.id)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateCache() {
|
||||||
|
_credsCache = null
|
||||||
|
_deptsCache = null
|
||||||
|
}
|
||||||
38
backend/src/routes/activity.js
Normal file
38
backend/src/routes/activity.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { requireAuth, hasCap } from '../auth.js'
|
||||||
|
import { pool } from '../db.js'
|
||||||
|
|
||||||
|
// Viewing the history of one specific event (the EventForm's inline "History"
|
||||||
|
// panel) only needs `view` — it's no more sensitive than the event itself.
|
||||||
|
// The unscoped/cross-calendar activity log is the admin-only surface.
|
||||||
|
async function requireActivityAccess(req, reply) {
|
||||||
|
const cap = req.query?.event_id ? 'view' : 'admin'
|
||||||
|
if (!hasCap(req, cap)) {
|
||||||
|
return reply.status(403).send({ error: `Missing capability: ${cap}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activityRoutes(app) {
|
||||||
|
app.addHook('preHandler', requireAuth)
|
||||||
|
|
||||||
|
// GET /api/activity?calendar_id=&event_id=&actor_email=&from=&to=&limit=50&offset=0
|
||||||
|
app.get('/api/activity', { preHandler: requireActivityAccess }, async (req) => {
|
||||||
|
const q = req.query || {}
|
||||||
|
const clauses = []
|
||||||
|
const vals = []
|
||||||
|
const push = v => { vals.push(v); return `$${vals.length}` }
|
||||||
|
|
||||||
|
if (q.calendar_id) clauses.push(`calendar_id = ${push(parseInt(q.calendar_id))}`)
|
||||||
|
if (q.event_id) clauses.push(`entity_type = 'event' AND entity_id = ${push(parseInt(q.event_id))}`)
|
||||||
|
if (q.actor_email) clauses.push(`actor_email = ${push(q.actor_email)}`)
|
||||||
|
if (q.from) clauses.push(`created_at >= ${push(`${q.from}T00:00:00Z`)}`)
|
||||||
|
if (q.to) clauses.push(`created_at <= ${push(`${q.to}T23:59:59Z`)}`)
|
||||||
|
|
||||||
|
const limit = Math.min(parseInt(q.limit) || 50, 200)
|
||||||
|
const offset = parseInt(q.offset) || 0
|
||||||
|
|
||||||
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''
|
||||||
|
const sql = `SELECT * FROM activity_log ${where} ORDER BY created_at DESC LIMIT ${push(limit)} OFFSET ${push(offset)}`
|
||||||
|
const { rows } = await pool.query(sql, vals)
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
}
|
||||||
67
backend/src/routes/attachments.js
Normal file
67
backend/src/routes/attachments.js
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { requireAuth, requireCap } from '../auth.js'
|
||||||
|
import { pool } from '../db.js'
|
||||||
|
import { logActivity } from '../lib/activity.js'
|
||||||
|
import { mkdir, unlink, writeFile } from 'fs/promises'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import { join, extname } from 'path'
|
||||||
|
|
||||||
|
export async function attachmentRoutes(app, opts) {
|
||||||
|
const UPLOADS_DIR = opts.uploadsDir
|
||||||
|
app.addHook('preHandler', requireAuth)
|
||||||
|
|
||||||
|
// POST /api/events/:id/attachments — multipart, field "file"
|
||||||
|
app.post('/api/events/:id/attachments', { preHandler: requireCap('edit') }, async (req, reply) => {
|
||||||
|
const eventId = parseInt(req.params.id)
|
||||||
|
const { rows } = await pool.query('SELECT id, calendar_id FROM events WHERE id = $1 AND deleted_at IS NULL', [eventId])
|
||||||
|
if (!rows.length) return reply.status(404).send({ error: 'Event not found' })
|
||||||
|
const event = rows[0]
|
||||||
|
|
||||||
|
const part = await req.file()
|
||||||
|
if (!part) return reply.status(400).send({ error: 'No file uploaded' })
|
||||||
|
|
||||||
|
const chunks = []
|
||||||
|
for await (const chunk of part.file) chunks.push(chunk)
|
||||||
|
const buffer = Buffer.concat(chunks)
|
||||||
|
|
||||||
|
const ext = extname(part.filename || '') || ''
|
||||||
|
const storedName = `${randomUUID()}${ext}`
|
||||||
|
const storedPath = `events/${eventId}/${storedName}`
|
||||||
|
const dir = join(UPLOADS_DIR, 'events', String(eventId))
|
||||||
|
await mkdir(dir, { recursive: true })
|
||||||
|
await writeFile(join(dir, storedName), buffer)
|
||||||
|
|
||||||
|
const { rows: ins } = await pool.query(
|
||||||
|
`INSERT INTO event_attachments (event_id, filename, stored_path, mime_type, size_bytes, uploaded_by)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||||||
|
[eventId, part.filename || storedName, storedPath, part.mimetype || null, buffer.length, req.user.email]
|
||||||
|
)
|
||||||
|
const attachment = ins[0]
|
||||||
|
|
||||||
|
await logActivity(pool, {
|
||||||
|
actorEmail: req.user.email, actorName: req.user.name, action: 'created', entityType: 'attachment',
|
||||||
|
entityId: attachment.id, calendarId: event.calendar_id, summary: `Added attachment "${attachment.filename}"`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return reply.status(201).send({ ...attachment, url: `/api/uploads/${attachment.stored_path}` })
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /api/attachments/:id
|
||||||
|
app.delete('/api/attachments/:id', { preHandler: requireCap('edit') }, async (req, reply) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT a.*, e.calendar_id FROM event_attachments a JOIN events e ON e.id = a.event_id WHERE a.id = $1`,
|
||||||
|
[req.params.id]
|
||||||
|
)
|
||||||
|
if (!rows.length) return reply.status(404).send({ error: 'Attachment not found' })
|
||||||
|
const attachment = rows[0]
|
||||||
|
|
||||||
|
await unlink(join(UPLOADS_DIR, attachment.stored_path)).catch(() => {})
|
||||||
|
await pool.query('DELETE FROM event_attachments WHERE id = $1', [attachment.id])
|
||||||
|
|
||||||
|
await logActivity(pool, {
|
||||||
|
actorEmail: req.user.email, actorName: req.user.name, action: 'deleted', entityType: 'attachment',
|
||||||
|
entityId: attachment.id, calendarId: attachment.calendar_id, summary: `Deleted attachment "${attachment.filename}"`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
70
backend/src/routes/caldav-credentials.js
Normal file
70
backend/src/routes/caldav-credentials.js
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { requireAuth, requireCap } from '../auth.js'
|
||||||
|
import { pool } from '../db.js'
|
||||||
|
import { logActivity } from '../lib/activity.js'
|
||||||
|
|
||||||
|
// CalDAV requests authenticate via HTTP Basic against caldav_credentials, NOT
|
||||||
|
// the session cookie/JWT — there is no live capability check available to a
|
||||||
|
// Basic-Auth CalDAV request today (no service-to-service capability endpoint
|
||||||
|
// exists in `auth` yet), so generating a credential is gated on
|
||||||
|
// calendar:edit at creation time (via this normal cookie-authenticated
|
||||||
|
// route) and, once issued, a valid credential grants full CalDAV read/write
|
||||||
|
// consistent with v1's flat, app-wide permission model. Revisit when/if a
|
||||||
|
// cross-service capability check exists.
|
||||||
|
export async function caldavCredentialRoutes(app) {
|
||||||
|
app.addHook('preHandler', requireAuth)
|
||||||
|
app.addHook('preHandler', requireCap('edit'))
|
||||||
|
|
||||||
|
// GET /api/caldav-credentials — the current user's own credentials
|
||||||
|
app.get('/api/caldav-credentials', async (req) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, username, label, created_at, last_used_at
|
||||||
|
FROM caldav_credentials WHERE user_email = $1 ORDER BY created_at DESC`,
|
||||||
|
[req.user.email]
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/caldav-credentials — body { label? }; password shown once
|
||||||
|
app.post('/api/caldav-credentials', async (req, reply) => {
|
||||||
|
const { label } = req.body || {}
|
||||||
|
const localpart = req.user.email.split('@')[0].replace(/[^a-z0-9]/gi, '').toLowerCase() || 'user'
|
||||||
|
const username = `${localpart}-${randomBytes(3).toString('hex')}`
|
||||||
|
const password = randomBytes(18).toString('base64url')
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10)
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO caldav_credentials (user_email, username, password_hash, label)
|
||||||
|
VALUES ($1,$2,$3,$4) RETURNING id, username, label, created_at`,
|
||||||
|
[req.user.email, username, passwordHash, label || null]
|
||||||
|
)
|
||||||
|
const credential = rows[0]
|
||||||
|
|
||||||
|
await logActivity(pool, {
|
||||||
|
actorEmail: req.user.email, actorName: req.user.name, action: 'created', entityType: 'caldav_credential',
|
||||||
|
entityId: credential.id, summary: `Created CalDAV credential "${username}"`,
|
||||||
|
details: { username, label: label || null },
|
||||||
|
})
|
||||||
|
|
||||||
|
return reply.status(201).send({ id: credential.id, username: credential.username, password, label: credential.label })
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /api/caldav-credentials/:id — only if it belongs to the requesting user
|
||||||
|
app.delete('/api/caldav-credentials/:id', async (req, reply) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT * FROM caldav_credentials WHERE id = $1 AND user_email = $2`,
|
||||||
|
[req.params.id, req.user.email]
|
||||||
|
)
|
||||||
|
if (!rows.length) return reply.status(404).send({ error: 'Credential not found' })
|
||||||
|
|
||||||
|
await pool.query('DELETE FROM caldav_credentials WHERE id = $1', [rows[0].id])
|
||||||
|
|
||||||
|
await logActivity(pool, {
|
||||||
|
actorEmail: req.user.email, actorName: req.user.name, action: 'deleted', entityType: 'caldav_credential',
|
||||||
|
entityId: rows[0].id, summary: `Deleted CalDAV credential "${rows[0].username}"`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
454
backend/src/routes/caldav.js
Normal file
454
backend/src/routes/caldav.js
Normal file
|
|
@ -0,0 +1,454 @@
|
||||||
|
// CalDAV server — hand-rolled RFC 4791 subset, NOT the `caldav-adapter` npm
|
||||||
|
// package. `caldav-adapter` (evaluated first, per spec) turned out to be a
|
||||||
|
// Koa-only middleware in practice: every route handler in its source
|
||||||
|
// (routes/calendar/*.js, routes/principal/*.js) is written directly against
|
||||||
|
// a Koa `ctx` (ctx.state, ctx.body, ctx.status, ctx.redirect, ctx.response.set)
|
||||||
|
// with no framework-agnostic entry point, despite "fastify" appearing in its
|
||||||
|
// package.json keywords. Wrapping that would mean building a full Koa-context
|
||||||
|
// shim around Fastify's request/reply for dozens of call sites — more risk
|
||||||
|
// than value versus a minimal, purpose-built implementation. Fastify natively
|
||||||
|
// supports the WebDAV verbs we need (PROPFIND, REPORT, MKCALENDAR are all in
|
||||||
|
// fastify/lib/httpMethods.js and in Node's http.METHODS), so routing them as
|
||||||
|
// plain Fastify routes is clean.
|
||||||
|
//
|
||||||
|
// Scope (deliberately minimal — only what Apple Calendar / Thunderbird need
|
||||||
|
// to sync, not the full RFC 4791 surface):
|
||||||
|
// - PROPFIND: root discovery, principal, calendar-home-set, calendar
|
||||||
|
// collections (depth 0/1), and per-resource props during depth-1 listing.
|
||||||
|
// - REPORT: calendar-query (with optional VEVENT time-range filter),
|
||||||
|
// calendar-multiget, sync-collection (RFC 6578, token = an ISO timestamp
|
||||||
|
// watermark — good enough for incremental sync, not a full change log).
|
||||||
|
// - GET/HEAD/PUT/DELETE on individual .ics resources, with If-Match /
|
||||||
|
// If-None-Match handled via a sequence+updated_at ETag.
|
||||||
|
// - MKCALENDAR always rejected (405) — v1 has no CalDAV calendar creation.
|
||||||
|
// - No RRULE support: incoming RRULEs are silently ignored (event is
|
||||||
|
// stored as a single one-off); v1 has no recurring events at all.
|
||||||
|
// - All-day events use DATE values only (never DATE-TIME), converting
|
||||||
|
// between our inclusive-last-day storage and RFC 5545's exclusive DTEND.
|
||||||
|
//
|
||||||
|
// Auth is HTTP Basic against caldav_credentials (see lib/caldav-store.js),
|
||||||
|
// completely separate from the cookie/JWT session used by /api/*.
|
||||||
|
import { DOMParser } from '@xmldom/xmldom'
|
||||||
|
import { pool } from '../db.js'
|
||||||
|
import {
|
||||||
|
authenticateBasic,
|
||||||
|
listCalendars,
|
||||||
|
getCalendarBySlug,
|
||||||
|
getCalendarCtag,
|
||||||
|
listEventsInCalendar,
|
||||||
|
getFullEvent,
|
||||||
|
getChangesSince,
|
||||||
|
etagFor,
|
||||||
|
eventToIcsString,
|
||||||
|
parseIcsEvent,
|
||||||
|
createEventFromCaldav,
|
||||||
|
updateEventFromCaldav,
|
||||||
|
deleteEventFromCaldav,
|
||||||
|
} from '../lib/caldav-store.js'
|
||||||
|
|
||||||
|
// Must match frontend/nginx.conf's `location /calendar/caldav/` -> backend
|
||||||
|
// `/caldav/` mapping. Hrefs in XML responses are client-facing paths, so
|
||||||
|
// they need the external `/calendar` prefix nginx strips on the way in.
|
||||||
|
const CALDAV_BASE = '/calendar/caldav'
|
||||||
|
|
||||||
|
// ---- small XML helpers (hand-written; no XML builder dependency needed for output) ----
|
||||||
|
|
||||||
|
function xmlEscape(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, c => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[c]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function propText(tag, value) {
|
||||||
|
return ` <${tag}>${xmlEscape(value)}</${tag}>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function propRaw(tag, innerXml) {
|
||||||
|
return ` <${tag}>${innerXml}</${tag}>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function propHref(tag, href) {
|
||||||
|
return ` <${tag}><D:href>${xmlEscape(href)}</D:href></${tag}>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function davResponse(href, propsXml, status = 'HTTP/1.1 200 OK') {
|
||||||
|
return ` <D:response>\n <D:href>${xmlEscape(href)}</D:href>\n <D:propstat>\n <D:prop>\n${propsXml}\n </D:prop>\n <D:status>${status}</D:status>\n </D:propstat>\n </D:response>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function davResponseNotFound(href) {
|
||||||
|
return ` <D:response>\n <D:href>${xmlEscape(href)}</D:href>\n <D:status>HTTP/1.1 404 Not Found</D:status>\n </D:response>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function multistatus(responsesXml, extraTail = '') {
|
||||||
|
return `<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">\n${responsesXml}${extraTail}\n</D:multistatus>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseXml(str) {
|
||||||
|
if (!str) return null
|
||||||
|
try {
|
||||||
|
return new DOMParser({
|
||||||
|
errorHandler: { warning: () => {}, error: () => {}, fatalError: (msg) => { throw new Error(msg) } },
|
||||||
|
}).parseFromString(str, 'text/xml')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function icsDateTimeToDate(s) {
|
||||||
|
const m = /^(\d{4})(\d{2})(\d{2})(T(\d{2})(\d{2})(\d{2})Z?)?$/.exec(String(s || ''))
|
||||||
|
if (!m) return null
|
||||||
|
const [, y, mo, d, , h = '00', mi = '00', se = '00'] = m
|
||||||
|
return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se))
|
||||||
|
}
|
||||||
|
|
||||||
|
function hrefToUid(href) {
|
||||||
|
if (!href) return null
|
||||||
|
const clean = href.split('?')[0].replace(/\/$/, '')
|
||||||
|
const segments = clean.split('/').filter(Boolean)
|
||||||
|
const last = segments[segments.length - 1]
|
||||||
|
if (!last) return null
|
||||||
|
return decodeURIComponent(last.replace(/\.ics$/i, ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTokenFor(date) {
|
||||||
|
return `sync-${date.toISOString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenFromSyncToken(token) {
|
||||||
|
const m = /^sync-(.+)$/.exec(String(token || ''))
|
||||||
|
if (!m) return null
|
||||||
|
const d = new Date(m[1])
|
||||||
|
return isNaN(d.getTime()) ? null : d
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- href builders (all client-facing, i.e. prefixed with CALDAV_BASE) ----
|
||||||
|
|
||||||
|
function encId(s) {
|
||||||
|
return encodeURIComponent(s)
|
||||||
|
}
|
||||||
|
function principalHref(email) {
|
||||||
|
return `${CALDAV_BASE}/principals/${encId(email)}/`
|
||||||
|
}
|
||||||
|
function calendarHomeHref(email) {
|
||||||
|
return `${CALDAV_BASE}/calendars/${encId(email)}/`
|
||||||
|
}
|
||||||
|
function calendarHref(email, slug) {
|
||||||
|
return `${CALDAV_BASE}/calendars/${encId(email)}/${encId(slug)}/`
|
||||||
|
}
|
||||||
|
function eventHref(email, slug, uid) {
|
||||||
|
return `${CALDAV_BASE}/calendars/${encId(email)}/${encId(slug)}/${encId(uid)}.ics`
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventPropsXml(href, event, calendarName, includeData) {
|
||||||
|
const parts = [propText('D:getetag', etagFor(event))]
|
||||||
|
if (includeData) {
|
||||||
|
parts.push(` <C:calendar-data>${xmlEscape(eventToIcsString(event, calendarName))}</C:calendar-data>`)
|
||||||
|
}
|
||||||
|
return davResponse(href, parts.join('\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- auth ----------------------------------------------------------------
|
||||||
|
|
||||||
|
async function caldavAuth(req, reply) {
|
||||||
|
if (req.method === 'OPTIONS') return
|
||||||
|
|
||||||
|
const authHeader = req.headers.authorization
|
||||||
|
if (!authHeader?.startsWith('Basic ')) {
|
||||||
|
reply.header('WWW-Authenticate', 'Basic realm="calendar"')
|
||||||
|
return reply.code(401).send()
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8')
|
||||||
|
const idx = decoded.indexOf(':')
|
||||||
|
const username = idx === -1 ? decoded : decoded.slice(0, idx)
|
||||||
|
const password = idx === -1 ? '' : decoded.slice(idx + 1)
|
||||||
|
|
||||||
|
const user = await authenticateBasic(pool, username, password)
|
||||||
|
if (!user) {
|
||||||
|
reply.header('WWW-Authenticate', 'Basic realm="calendar"')
|
||||||
|
return reply.code(401).send()
|
||||||
|
}
|
||||||
|
req.caldavUser = user
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- REPORT sub-handlers ---------------------------------------------------
|
||||||
|
|
||||||
|
async function handleCalendarQuery(reply, email, cal, doc) {
|
||||||
|
const { rows: events } = await pool.query(
|
||||||
|
'SELECT * FROM events WHERE calendar_id = $1 AND deleted_at IS NULL ORDER BY uid', [cal.id]
|
||||||
|
)
|
||||||
|
let filtered = events
|
||||||
|
const timeRangeEl = doc?.getElementsByTagNameNS?.('urn:ietf:params:xml:ns:caldav', 'time-range')?.[0]
|
||||||
|
if (timeRangeEl) {
|
||||||
|
const start = icsDateTimeToDate(timeRangeEl.getAttribute('start'))
|
||||||
|
const end = icsDateTimeToDate(timeRangeEl.getAttribute('end'))
|
||||||
|
filtered = events.filter(e =>
|
||||||
|
(!end || new Date(e.start_at) <= end) && (!start || new Date(e.end_at) >= start)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = filtered.map(e => eventPropsXml(eventHref(email, cal.slug, e.uid), e, cal.name, true))
|
||||||
|
reply.code(207).header('Content-Type', 'application/xml; charset=utf-8').send(multistatus(responses.join('\n')))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMultiget(reply, email, cal, doc) {
|
||||||
|
const hrefEls = doc?.getElementsByTagNameNS?.('DAV:', 'href') || []
|
||||||
|
const responses = []
|
||||||
|
for (let i = 0; i < hrefEls.length; i++) {
|
||||||
|
const uid = hrefToUid(hrefEls[i].textContent)
|
||||||
|
if (!uid) continue
|
||||||
|
const ev = await getFullEvent(pool, cal.id, uid)
|
||||||
|
if (!ev) continue
|
||||||
|
responses.push(eventPropsXml(eventHref(email, cal.slug, ev.uid), ev, cal.name, true))
|
||||||
|
}
|
||||||
|
reply.code(207).header('Content-Type', 'application/xml; charset=utf-8').send(multistatus(responses.join('\n')))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSyncCollection(reply, email, cal, doc) {
|
||||||
|
const tokenEl = doc?.getElementsByTagNameNS?.('DAV:', 'sync-token')?.[0]
|
||||||
|
const since = tokenEl?.textContent ? tokenFromSyncToken(tokenEl.textContent.trim()) : null
|
||||||
|
|
||||||
|
// Capture the new watermark BEFORE querying, not after: if a write commits
|
||||||
|
// in the gap between the query and the token, we want the next sync to
|
||||||
|
// re-see it (harmless — just re-delivers an already-known event) rather
|
||||||
|
// than silently drop it (the token would then be newer than the write's
|
||||||
|
// updated_at, so a `since`-filtered query would never surface it again).
|
||||||
|
const newToken = syncTokenFor(new Date())
|
||||||
|
const { changed, removed } = await getChangesSince(pool, cal.id, since)
|
||||||
|
const responses = []
|
||||||
|
for (const partial of changed) {
|
||||||
|
const full = await getFullEvent(pool, cal.id, partial.uid)
|
||||||
|
if (!full) continue
|
||||||
|
responses.push(eventPropsXml(eventHref(email, cal.slug, full.uid), full, cal.name, true))
|
||||||
|
}
|
||||||
|
for (const r of removed) {
|
||||||
|
responses.push(davResponseNotFound(eventHref(email, cal.slug, r.uid)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const tail = `\n <D:sync-token>${xmlEscape(newToken)}</D:sync-token>`
|
||||||
|
reply.code(207).header('Content-Type', 'application/xml; charset=utf-8').send(multistatus(responses.join('\n'), tail))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function caldavRoutes(app) {
|
||||||
|
// Raw string body for XML (PROPFIND/REPORT) and text/calendar (PUT) —
|
||||||
|
// none of these match Fastify's default JSON parser.
|
||||||
|
app.addContentTypeParser('*', { parseAs: 'string' }, (req, body, done) => done(null, body))
|
||||||
|
|
||||||
|
app.addHook('preHandler', caldavAuth)
|
||||||
|
|
||||||
|
// ---- discovery ----
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: ['OPTIONS'],
|
||||||
|
url: '/caldav',
|
||||||
|
handler: optionsHandler,
|
||||||
|
})
|
||||||
|
app.route({
|
||||||
|
method: ['OPTIONS'],
|
||||||
|
url: '/caldav/*',
|
||||||
|
handler: optionsHandler,
|
||||||
|
})
|
||||||
|
function optionsHandler(req, reply) {
|
||||||
|
reply
|
||||||
|
.header('Allow', 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT')
|
||||||
|
.header('DAV', '1, 2, 3, calendar-access')
|
||||||
|
.code(200).send()
|
||||||
|
}
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: ['PROPFIND'],
|
||||||
|
url: '/caldav/',
|
||||||
|
handler: async (req, reply) => {
|
||||||
|
const email = req.caldavUser.email
|
||||||
|
const body = multistatus(davResponse(`${CALDAV_BASE}/`, [
|
||||||
|
propHref('D:current-user-principal', principalHref(email)),
|
||||||
|
propRaw('D:resourcetype', '<D:collection/>'),
|
||||||
|
].join('\n')))
|
||||||
|
reply.code(207).header('Content-Type', 'application/xml; charset=utf-8').send(body)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- principal ----
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: ['PROPFIND'],
|
||||||
|
url: '/caldav/principals/:principal/',
|
||||||
|
handler: async (req, reply) => {
|
||||||
|
const email = req.caldavUser.email
|
||||||
|
const body = multistatus(davResponse(principalHref(email), [
|
||||||
|
propRaw('D:resourcetype', '<D:collection/><D:principal/>'),
|
||||||
|
propText('D:displayname', email),
|
||||||
|
propHref('C:calendar-home-set', calendarHomeHref(email)),
|
||||||
|
propHref('D:current-user-principal', principalHref(email)),
|
||||||
|
propHref('D:principal-URL', principalHref(email)),
|
||||||
|
].join('\n')))
|
||||||
|
reply.code(207).header('Content-Type', 'application/xml; charset=utf-8').send(body)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- calendar-home-set (list all calendars) ----
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: ['PROPFIND'],
|
||||||
|
url: '/caldav/calendars/:principal/',
|
||||||
|
handler: async (req, reply) => {
|
||||||
|
const email = req.caldavUser.email
|
||||||
|
const depth = req.headers.depth ?? '0'
|
||||||
|
const responses = [davResponse(calendarHomeHref(email), [
|
||||||
|
propRaw('D:resourcetype', '<D:collection/>'),
|
||||||
|
propText('D:displayname', 'Calendars'),
|
||||||
|
].join('\n'))]
|
||||||
|
|
||||||
|
if (depth !== '0') {
|
||||||
|
const calendars = await listCalendars(pool)
|
||||||
|
for (const cal of calendars) {
|
||||||
|
const ctag = await getCalendarCtag(pool, cal.id)
|
||||||
|
responses.push(davResponse(calendarHref(email, cal.slug), [
|
||||||
|
propRaw('D:resourcetype', '<D:collection/><C:calendar/>'),
|
||||||
|
propText('D:displayname', cal.name),
|
||||||
|
propRaw('C:supported-calendar-component-set', '<C:comp name="VEVENT"/>'),
|
||||||
|
propText('CS:getctag', ctag),
|
||||||
|
propText('ICAL:calendar-color', cal.color),
|
||||||
|
].join('\n')))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reply.code(207).header('Content-Type', 'application/xml; charset=utf-8').send(multistatus(responses.join('\n')))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- a specific calendar collection: PROPFIND + REPORT ----
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: ['PROPFIND'],
|
||||||
|
url: '/caldav/calendars/:principal/:slug/',
|
||||||
|
handler: async (req, reply) => {
|
||||||
|
const email = req.caldavUser.email
|
||||||
|
const cal = await getCalendarBySlug(pool, req.params.slug)
|
||||||
|
if (!cal) return reply.code(404).send()
|
||||||
|
const depth = req.headers.depth ?? '0'
|
||||||
|
|
||||||
|
const ctag = await getCalendarCtag(pool, cal.id)
|
||||||
|
const responses = [davResponse(calendarHref(email, cal.slug), [
|
||||||
|
propRaw('D:resourcetype', '<D:collection/><C:calendar/>'),
|
||||||
|
propText('D:displayname', cal.name),
|
||||||
|
propRaw('C:supported-calendar-component-set', '<C:comp name="VEVENT"/>'),
|
||||||
|
propText('CS:getctag', ctag),
|
||||||
|
propText('ICAL:calendar-color', cal.color),
|
||||||
|
].join('\n'))]
|
||||||
|
|
||||||
|
if (depth !== '0') {
|
||||||
|
const events = await listEventsInCalendar(pool, cal.id)
|
||||||
|
for (const ev of events) {
|
||||||
|
responses.push(davResponse(eventHref(email, cal.slug, ev.uid), propText('D:getetag', etagFor(ev))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reply.code(207).header('Content-Type', 'application/xml; charset=utf-8').send(multistatus(responses.join('\n')))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: ['REPORT'],
|
||||||
|
url: '/caldav/calendars/:principal/:slug/',
|
||||||
|
handler: async (req, reply) => {
|
||||||
|
const email = req.caldavUser.email
|
||||||
|
const cal = await getCalendarBySlug(pool, req.params.slug)
|
||||||
|
if (!cal) return reply.code(404).send()
|
||||||
|
|
||||||
|
const doc = parseXml(req.body)
|
||||||
|
const root = doc?.documentElement
|
||||||
|
const rootName = root?.localName || root?.tagName || ''
|
||||||
|
|
||||||
|
if (rootName === 'sync-collection') return handleSyncCollection(reply, email, cal, doc)
|
||||||
|
if (rootName === 'calendar-multiget') return handleMultiget(reply, email, cal, doc)
|
||||||
|
return handleCalendarQuery(reply, email, cal, doc)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// MKCALENDAR — no CalDAV-side calendar creation in v1.
|
||||||
|
app.route({
|
||||||
|
method: ['MKCALENDAR'],
|
||||||
|
url: '/caldav/calendars/:principal/:slug',
|
||||||
|
handler: async (req, reply) => {
|
||||||
|
reply.code(405).header('Allow', 'PROPFIND, REPORT, OPTIONS').send()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- individual event resources: GET/HEAD/PUT/DELETE ----
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: ['GET', 'HEAD'],
|
||||||
|
url: '/caldav/calendars/:principal/:slug/:filename',
|
||||||
|
handler: async (req, reply) => {
|
||||||
|
const cal = await getCalendarBySlug(pool, req.params.slug)
|
||||||
|
if (!cal) return reply.code(404).send()
|
||||||
|
const uid = decodeURIComponent(req.params.filename).replace(/\.ics$/i, '')
|
||||||
|
const ev = await getFullEvent(pool, cal.id, uid)
|
||||||
|
if (!ev) return reply.code(404).send()
|
||||||
|
|
||||||
|
reply.header('Content-Type', 'text/calendar; charset=utf-8').header('ETag', etagFor(ev))
|
||||||
|
if (req.method === 'HEAD') return reply.code(200).send()
|
||||||
|
return reply.send(eventToIcsString(ev, cal.name))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: ['PUT'],
|
||||||
|
url: '/caldav/calendars/:principal/:slug/:filename',
|
||||||
|
handler: async (req, reply) => {
|
||||||
|
const email = req.caldavUser.email
|
||||||
|
const cal = await getCalendarBySlug(pool, req.params.slug)
|
||||||
|
if (!cal) return reply.code(404).send()
|
||||||
|
if (cal.is_system) return reply.code(403).send('Cannot write to a system calendar')
|
||||||
|
|
||||||
|
let parsed
|
||||||
|
try {
|
||||||
|
parsed = parseIcsEvent(req.body || '')
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(400).send(err.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The resource URL is the identity; fall back to the body's own UID
|
||||||
|
// only if the client didn't send one in the URL (shouldn't happen).
|
||||||
|
const uid = decodeURIComponent(req.params.filename).replace(/\.ics$/i, '') || parsed.uid
|
||||||
|
|
||||||
|
const existing = await getFullEvent(pool, cal.id, uid)
|
||||||
|
const ifMatch = req.headers['if-match']
|
||||||
|
const ifNoneMatch = req.headers['if-none-match']
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (ifNoneMatch === '*') return reply.code(412).send()
|
||||||
|
if (ifMatch && ifMatch !== etagFor(existing)) return reply.code(412).send()
|
||||||
|
await updateEventFromCaldav(pool, existing.id, { ...parsed, uid }, email)
|
||||||
|
} else {
|
||||||
|
if (ifMatch) return reply.code(412).send()
|
||||||
|
await createEventFromCaldav(pool, cal.id, { ...parsed, uid }, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
const full = await getFullEvent(pool, cal.id, uid)
|
||||||
|
reply.code(existing ? 204 : 201).header('ETag', etagFor(full)).send()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: ['DELETE'],
|
||||||
|
url: '/caldav/calendars/:principal/:slug/:filename',
|
||||||
|
handler: async (req, reply) => {
|
||||||
|
const email = req.caldavUser.email
|
||||||
|
const cal = await getCalendarBySlug(pool, req.params.slug)
|
||||||
|
if (!cal) return reply.code(404).send()
|
||||||
|
if (cal.is_system) return reply.code(403).send('Cannot write to a system calendar')
|
||||||
|
|
||||||
|
const uid = decodeURIComponent(req.params.filename).replace(/\.ics$/i, '')
|
||||||
|
const existing = await getFullEvent(pool, cal.id, uid)
|
||||||
|
if (!existing) return reply.code(404).send()
|
||||||
|
|
||||||
|
const ifMatch = req.headers['if-match']
|
||||||
|
if (ifMatch && ifMatch !== etagFor(existing)) return reply.code(412).send()
|
||||||
|
|
||||||
|
await deleteEventFromCaldav(pool, existing.id, email)
|
||||||
|
reply.code(204).send()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
96
backend/src/routes/calendars.js
Normal file
96
backend/src/routes/calendars.js
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { requireAuth, requireCap } from '../auth.js'
|
||||||
|
import { pool } from '../db.js'
|
||||||
|
import { logActivity } from '../lib/activity.js'
|
||||||
|
|
||||||
|
function slugify(name) {
|
||||||
|
return String(name)
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '') || 'calendar'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uniqueSlug(base) {
|
||||||
|
let slug = base
|
||||||
|
let n = 2
|
||||||
|
while (true) {
|
||||||
|
const { rows } = await pool.query('SELECT 1 FROM calendars WHERE slug = $1', [slug])
|
||||||
|
if (!rows.length) return slug
|
||||||
|
slug = `${base}-${n}`
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function calendarRoutes(app) {
|
||||||
|
app.addHook('preHandler', requireAuth)
|
||||||
|
|
||||||
|
// GET /api/calendars
|
||||||
|
app.get('/api/calendars', { preHandler: requireCap('view') }, async () => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT * FROM calendars WHERE deleted_at IS NULL ORDER BY is_system ASC, name ASC`
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/calendars
|
||||||
|
app.post('/api/calendars', { preHandler: requireCap('manage_calendars') }, async (req, reply) => {
|
||||||
|
const { name, color } = req.body || {}
|
||||||
|
if (!name || !color) return reply.status(400).send({ error: 'name and color required' })
|
||||||
|
|
||||||
|
const slug = await uniqueSlug(slugify(name))
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO calendars (slug, name, color, is_system, created_by)
|
||||||
|
VALUES ($1, $2, $3, FALSE, $4) RETURNING *`,
|
||||||
|
[slug, name, color, req.user.email]
|
||||||
|
)
|
||||||
|
const calendar = rows[0]
|
||||||
|
|
||||||
|
await logActivity(pool, {
|
||||||
|
actorEmail: req.user.email, actorName: req.user.name, action: 'created', entityType: 'calendar',
|
||||||
|
entityId: calendar.id, calendarId: calendar.id, summary: `Created calendar "${calendar.name}"`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return reply.status(201).send(calendar)
|
||||||
|
})
|
||||||
|
|
||||||
|
// PATCH /api/calendars/:id
|
||||||
|
app.patch('/api/calendars/:id', { preHandler: requireCap('manage_calendars') }, async (req, reply) => {
|
||||||
|
const id = parseInt(req.params.id)
|
||||||
|
const { rows: existing } = await pool.query('SELECT * FROM calendars WHERE id = $1 AND deleted_at IS NULL', [id])
|
||||||
|
if (!existing.length) return reply.status(404).send({ error: 'Calendar not found' })
|
||||||
|
if (existing[0].is_system) return reply.status(400).send({ error: 'Cannot edit a system calendar' })
|
||||||
|
|
||||||
|
const b = req.body || {}
|
||||||
|
const name = b.name ?? existing[0].name
|
||||||
|
const color = b.color ?? existing[0].color
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE calendars SET name = $1, color = $2 WHERE id = $3 RETURNING *`,
|
||||||
|
[name, color, id]
|
||||||
|
)
|
||||||
|
|
||||||
|
await logActivity(pool, {
|
||||||
|
actorEmail: req.user.email, actorName: req.user.name, action: 'updated', entityType: 'calendar',
|
||||||
|
entityId: id, calendarId: id, summary: `Updated calendar "${rows[0].name}"`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return rows[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /api/calendars/:id — soft delete
|
||||||
|
app.delete('/api/calendars/:id', { preHandler: requireCap('manage_calendars') }, async (req, reply) => {
|
||||||
|
const id = parseInt(req.params.id)
|
||||||
|
const { rows: existing } = await pool.query('SELECT * FROM calendars WHERE id = $1 AND deleted_at IS NULL', [id])
|
||||||
|
if (!existing.length) return reply.status(404).send({ error: 'Calendar not found' })
|
||||||
|
if (existing[0].is_system) return reply.status(400).send({ error: 'Cannot delete a system calendar' })
|
||||||
|
|
||||||
|
await pool.query('UPDATE calendars SET deleted_at = NOW() WHERE id = $1', [id])
|
||||||
|
|
||||||
|
await logActivity(pool, {
|
||||||
|
actorEmail: req.user.email, actorName: req.user.name, action: 'deleted', entityType: 'calendar',
|
||||||
|
entityId: id, calendarId: id, summary: `Deleted calendar "${existing[0].name}"`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
41
backend/src/routes/departments.js
Normal file
41
backend/src/routes/departments.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { requireAuth, requireCap } from '../auth.js'
|
||||||
|
import { pool } from '../db.js'
|
||||||
|
import { getAllDepartments, getDepartmentsForEmail } from '../lib/workforce.js'
|
||||||
|
import { queryEventSummaries } from './events.js'
|
||||||
|
|
||||||
|
export async function departmentRoutes(app) {
|
||||||
|
app.addHook('preHandler', requireAuth)
|
||||||
|
app.addHook('preHandler', requireCap('view'))
|
||||||
|
|
||||||
|
// GET /api/departments — all Workforce departments, for the event tagging picker
|
||||||
|
app.get('/api/departments', async () => {
|
||||||
|
try {
|
||||||
|
const depts = await getAllDepartments()
|
||||||
|
return depts.map(d => ({ id: String(d.id), name: d.name }))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/me/departments
|
||||||
|
app.get('/api/me/departments', async (req) => {
|
||||||
|
const depts = await getDepartmentsForEmail(req.user.email)
|
||||||
|
return depts.map(d => ({ id: String(d.id), name: d.name }))
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/me/upcoming?days=7
|
||||||
|
app.get('/api/me/upcoming', async (req) => {
|
||||||
|
const days = Math.max(1, Math.min(parseInt(req.query?.days) || 7, 90))
|
||||||
|
const today = new Date()
|
||||||
|
const from = today.toISOString().slice(0, 10) + 'T00:00:00Z'
|
||||||
|
const end = new Date(today.getTime() + days * 24 * 60 * 60 * 1000)
|
||||||
|
const to = end.toISOString().slice(0, 10) + 'T23:59:59Z'
|
||||||
|
|
||||||
|
const depts = await getDepartmentsForEmail(req.user.email)
|
||||||
|
const mineDeptIds = depts.map(d => String(d.id))
|
||||||
|
|
||||||
|
return queryEventSummaries(pool, {
|
||||||
|
from, to, mineEmail: req.user.email, mineDeptIds,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
328
backend/src/routes/events.js
Normal file
328
backend/src/routes/events.js
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import { requireAuth, requireCap } from '../auth.js'
|
||||||
|
import { pool } from '../db.js'
|
||||||
|
import { logActivity } from '../lib/activity.js'
|
||||||
|
import { getDepartmentsForEmail } from '../lib/workforce.js'
|
||||||
|
|
||||||
|
// Single source of truth for event mutation logic — both the Fastify route
|
||||||
|
// handlers below AND lib/caldav-store.js import and call these so a change
|
||||||
|
// made via CalDAV and a change made via the web UI go through identical
|
||||||
|
// validation, join-table handling and activity logging (source differs).
|
||||||
|
|
||||||
|
// Column aliases here (department_names/assignee_names) match the frontend's
|
||||||
|
// EventSummary type exactly — it flattens tags down to name arrays for list/
|
||||||
|
// agenda views. EventDetail (see getEventDetail) extends EventSummary, so it
|
||||||
|
// carries these same flat arrays *and* the richer {id,name}/{email,name}
|
||||||
|
// object arrays for the edit form.
|
||||||
|
const EVENT_SUMMARY_SELECT = `
|
||||||
|
SELECT e.id, e.calendar_id, c.name AS calendar_name, c.color AS calendar_color,
|
||||||
|
e.uid, e.title, e.location, e.start_at, e.end_at, e.all_day,
|
||||||
|
COALESCE(d.department_names, '[]'::json) AS department_names,
|
||||||
|
COALESCE(a.assignee_names, '[]'::json) AS assignee_names,
|
||||||
|
COALESCE(att.attachment_count, 0)::int AS attachment_count
|
||||||
|
FROM events e
|
||||||
|
JOIN calendars c ON c.id = e.calendar_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT json_agg(ed.department_name ORDER BY ed.department_name) AS department_names
|
||||||
|
FROM event_departments ed WHERE ed.event_id = e.id
|
||||||
|
) d ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT json_agg(COALESCE(ea.user_name, ea.user_email) ORDER BY COALESCE(ea.user_name, ea.user_email)) AS assignee_names
|
||||||
|
FROM event_assignees ea WHERE ea.event_id = e.id
|
||||||
|
) a ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(*) AS attachment_count FROM event_attachments att2 WHERE att2.event_id = e.id
|
||||||
|
) att ON TRUE
|
||||||
|
`
|
||||||
|
|
||||||
|
const DIFF_FIELDS = ['title', 'description', 'location', 'start_at', 'end_at', 'all_day']
|
||||||
|
|
||||||
|
function normalizeForDiff(v) {
|
||||||
|
if (v == null) return v
|
||||||
|
if (v instanceof Date) return v.toISOString()
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceDepartments(pool, eventId, departments) {
|
||||||
|
await pool.query('DELETE FROM event_departments WHERE event_id = $1', [eventId])
|
||||||
|
for (const d of departments) {
|
||||||
|
if (!d?.id) continue
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO event_departments (event_id, department_id, department_name)
|
||||||
|
VALUES ($1,$2,$3) ON CONFLICT DO NOTHING`,
|
||||||
|
[eventId, String(d.id), d.name || String(d.id)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceAssignees(pool, eventId, assignees) {
|
||||||
|
await pool.query('DELETE FROM event_assignees WHERE event_id = $1', [eventId])
|
||||||
|
for (const a of assignees) {
|
||||||
|
if (!a?.email) continue
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO event_assignees (event_id, user_email, user_name)
|
||||||
|
VALUES ($1,$2,$3) ON CONFLICT DO NOTHING`,
|
||||||
|
[eventId, a.email, a.name || a.email]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/events list query, also reused by GET /api/me/upcoming.
|
||||||
|
export async function queryEventSummaries(pool, { from, to, calendarId, departmentId, mineEmail, mineDeptIds } = {}) {
|
||||||
|
const clauses = ['e.deleted_at IS NULL']
|
||||||
|
const vals = []
|
||||||
|
const push = v => { vals.push(v); return `$${vals.length}` }
|
||||||
|
|
||||||
|
if (from) clauses.push(`e.end_at >= ${push(from)}`)
|
||||||
|
if (to) clauses.push(`e.start_at <= ${push(to)}`)
|
||||||
|
if (calendarId) clauses.push(`e.calendar_id = ${push(calendarId)}`)
|
||||||
|
if (departmentId) {
|
||||||
|
clauses.push(`EXISTS (SELECT 1 FROM event_departments ed WHERE ed.event_id = e.id AND ed.department_id = ${push(String(departmentId))})`)
|
||||||
|
}
|
||||||
|
if (mineEmail) {
|
||||||
|
const deptIds = mineDeptIds || []
|
||||||
|
if (deptIds.length) {
|
||||||
|
clauses.push(`(
|
||||||
|
EXISTS (SELECT 1 FROM event_assignees ea WHERE ea.event_id = e.id AND ea.user_email = ${push(mineEmail)})
|
||||||
|
OR EXISTS (SELECT 1 FROM event_departments ed WHERE ed.event_id = e.id AND ed.department_id = ANY(${push(deptIds)}))
|
||||||
|
)`)
|
||||||
|
} else {
|
||||||
|
clauses.push(`EXISTS (SELECT 1 FROM event_assignees ea WHERE ea.event_id = e.id AND ea.user_email = ${push(mineEmail)})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `${EVENT_SUMMARY_SELECT} WHERE ${clauses.join(' AND ')} ORDER BY e.start_at ASC`
|
||||||
|
const { rows } = await pool.query(sql, vals)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEventDetail(pool, id) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT e.*, c.id AS cal_id, c.name AS cal_name, c.color AS cal_color, c.is_system AS cal_is_system
|
||||||
|
FROM events e JOIN calendars c ON c.id = e.calendar_id
|
||||||
|
WHERE e.id = $1 AND e.deleted_at IS NULL`,
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
if (!rows.length) return null
|
||||||
|
const row = rows[0]
|
||||||
|
|
||||||
|
const [{ rows: departments }, { rows: assignees }, { rows: attachments }] = await Promise.all([
|
||||||
|
pool.query('SELECT department_id AS id, department_name AS name FROM event_departments WHERE event_id = $1 ORDER BY department_name', [id]),
|
||||||
|
pool.query('SELECT user_email AS email, user_name AS name FROM event_assignees WHERE event_id = $1 ORDER BY user_name', [id]),
|
||||||
|
pool.query('SELECT id, filename, mime_type, size_bytes, stored_path FROM event_attachments WHERE event_id = $1 ORDER BY created_at', [id]),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
// EventSummary fields (EventDetail extends EventSummary on the frontend)
|
||||||
|
id: row.id,
|
||||||
|
calendar_id: row.calendar_id,
|
||||||
|
calendar_name: row.cal_name,
|
||||||
|
calendar_color: row.cal_color,
|
||||||
|
uid: row.uid,
|
||||||
|
title: row.title,
|
||||||
|
location: row.location,
|
||||||
|
start_at: row.start_at,
|
||||||
|
end_at: row.end_at,
|
||||||
|
all_day: row.all_day,
|
||||||
|
department_names: departments.map(d => d.name),
|
||||||
|
assignee_names: assignees.map(a => a.name),
|
||||||
|
attachment_count: attachments.length,
|
||||||
|
// EventDetail-only fields
|
||||||
|
description: row.description,
|
||||||
|
sequence: row.sequence,
|
||||||
|
created_by: row.created_by,
|
||||||
|
created_by_name: row.created_by_name,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
created_at: row.created_at,
|
||||||
|
departments,
|
||||||
|
assignees,
|
||||||
|
attachments: attachments.map(a => ({
|
||||||
|
id: a.id, filename: a.filename, mime_type: a.mime_type, size_bytes: a.size_bytes,
|
||||||
|
url: `/api/uploads/${a.stored_path}`,
|
||||||
|
})),
|
||||||
|
calendar: { id: row.cal_id, name: row.cal_name, color: row.cal_color, is_system: row.cal_is_system },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// data: { calendar_id, title, description?, location?, start_at, end_at, all_day,
|
||||||
|
// departments?: [{id,name}], assignees?: [{email,name}], uid? }
|
||||||
|
// actor: { email, name }
|
||||||
|
export async function createEvent(pool, data, actor, source = 'web') {
|
||||||
|
const uid = data.uid || `${randomUUID()}@calendar.hotelnumberfour.com`
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO events (calendar_id, uid, title, description, location, start_at, end_at, all_day, created_by, created_by_name)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id`,
|
||||||
|
[
|
||||||
|
data.calendar_id,
|
||||||
|
uid,
|
||||||
|
data.title,
|
||||||
|
data.description || null,
|
||||||
|
data.location || null,
|
||||||
|
data.start_at,
|
||||||
|
data.end_at,
|
||||||
|
!!data.all_day,
|
||||||
|
actor.email || null,
|
||||||
|
actor.name || null,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
const eventId = rows[0].id
|
||||||
|
|
||||||
|
if (data.departments !== undefined) await replaceDepartments(pool, eventId, data.departments)
|
||||||
|
if (data.assignees !== undefined) await replaceAssignees(pool, eventId, data.assignees)
|
||||||
|
|
||||||
|
await logActivity(pool, {
|
||||||
|
actorEmail: actor.email, actorName: actor.name, action: 'created', entityType: 'event',
|
||||||
|
entityId: eventId, calendarId: data.calendar_id, summary: `Created event "${data.title}"`, source,
|
||||||
|
})
|
||||||
|
|
||||||
|
return getEventDetail(pool, eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// data: partial — same shape as createEvent. Fields omitted entirely are left
|
||||||
|
// unchanged (this matters for CalDAV updates, which never send departments/
|
||||||
|
// assignees and must not wipe them).
|
||||||
|
export async function updateEvent(pool, id, data, actor, source = 'web') {
|
||||||
|
const { rows: existingRows } = await pool.query('SELECT * FROM events WHERE id = $1 AND deleted_at IS NULL', [id])
|
||||||
|
if (!existingRows.length) return null
|
||||||
|
const existing = existingRows[0]
|
||||||
|
|
||||||
|
const merged = {
|
||||||
|
title: data.title ?? existing.title,
|
||||||
|
description: data.description !== undefined ? data.description : existing.description,
|
||||||
|
location: data.location !== undefined ? data.location : existing.location,
|
||||||
|
start_at: data.start_at ?? existing.start_at,
|
||||||
|
end_at: data.end_at ?? existing.end_at,
|
||||||
|
all_day: data.all_day !== undefined ? !!data.all_day : existing.all_day,
|
||||||
|
calendar_id: data.calendar_id ?? existing.calendar_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE events SET
|
||||||
|
title = $1, description = $2, location = $3, start_at = $4, end_at = $5, all_day = $6,
|
||||||
|
calendar_id = $7, sequence = sequence + 1, updated_at = NOW()
|
||||||
|
WHERE id = $8`,
|
||||||
|
[merged.title, merged.description, merged.location, merged.start_at, merged.end_at, merged.all_day, merged.calendar_id, id]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (data.departments !== undefined) await replaceDepartments(pool, id, data.departments)
|
||||||
|
if (data.assignees !== undefined) await replaceAssignees(pool, id, data.assignees)
|
||||||
|
|
||||||
|
const changes = {}
|
||||||
|
for (const f of DIFF_FIELDS) {
|
||||||
|
if (data[f] === undefined) continue
|
||||||
|
const before = normalizeForDiff(existing[f])
|
||||||
|
const after = normalizeForDiff(data[f])
|
||||||
|
if (String(before) !== String(after)) changes[f] = { from: before, to: after }
|
||||||
|
}
|
||||||
|
|
||||||
|
await logActivity(pool, {
|
||||||
|
actorEmail: actor.email, actorName: actor.name, action: 'updated', entityType: 'event',
|
||||||
|
entityId: id, calendarId: merged.calendar_id, summary: `Updated event "${merged.title}"`,
|
||||||
|
details: Object.keys(changes).length ? changes : null, source,
|
||||||
|
})
|
||||||
|
|
||||||
|
return getEventDetail(pool, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEvent(pool, id, actor, source = 'web') {
|
||||||
|
const { rows } = await pool.query('SELECT id, title, calendar_id FROM events WHERE id = $1 AND deleted_at IS NULL', [id])
|
||||||
|
if (!rows.length) return false
|
||||||
|
const event = rows[0]
|
||||||
|
|
||||||
|
await pool.query('UPDATE events SET deleted_at = NOW() WHERE id = $1', [id])
|
||||||
|
|
||||||
|
await logActivity(pool, {
|
||||||
|
actorEmail: actor.email, actorName: actor.name, action: 'deleted', entityType: 'event',
|
||||||
|
entityId: id, calendarId: event.calendar_id, summary: `Deleted event "${event.title}"`, source,
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eventRoutes(app) {
|
||||||
|
app.addHook('preHandler', requireAuth)
|
||||||
|
|
||||||
|
// GET /api/events?from=&to=&calendar_id=&department_id=&mine=true
|
||||||
|
app.get('/api/events', { preHandler: requireCap('view') }, async (req) => {
|
||||||
|
const q = req.query || {}
|
||||||
|
const from = q.from ? `${q.from}T00:00:00Z` : null
|
||||||
|
const to = q.to ? `${q.to}T23:59:59Z` : null
|
||||||
|
|
||||||
|
let mineDeptIds = []
|
||||||
|
if (q.mine === 'true') {
|
||||||
|
const depts = await getDepartmentsForEmail(req.user.email)
|
||||||
|
mineDeptIds = depts.map(d => String(d.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryEventSummaries(pool, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
calendarId: q.calendar_id ? parseInt(q.calendar_id) : null,
|
||||||
|
departmentId: q.department_id || null,
|
||||||
|
mineEmail: q.mine === 'true' ? req.user.email : null,
|
||||||
|
mineDeptIds,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/events/:id — full detail
|
||||||
|
app.get('/api/events/:id', { preHandler: requireCap('view') }, async (req, reply) => {
|
||||||
|
const detail = await getEventDetail(pool, parseInt(req.params.id))
|
||||||
|
if (!detail) return reply.status(404).send({ error: 'Event not found' })
|
||||||
|
return detail
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/events
|
||||||
|
app.post('/api/events', { preHandler: requireCap('create') }, async (req, reply) => {
|
||||||
|
const b = req.body || {}
|
||||||
|
if (!b.calendar_id || !b.title || !b.start_at || !b.end_at) {
|
||||||
|
return reply.status(400).send({ error: 'calendar_id, title, start_at and end_at are required' })
|
||||||
|
}
|
||||||
|
const { rows: cal } = await pool.query('SELECT id, is_system FROM calendars WHERE id = $1 AND deleted_at IS NULL', [b.calendar_id])
|
||||||
|
if (!cal.length) return reply.status(400).send({ error: 'Unknown calendar' })
|
||||||
|
if (cal[0].is_system) return reply.status(400).send({ error: 'Cannot create events directly on a system calendar' })
|
||||||
|
|
||||||
|
const detail = await createEvent(pool, b, { email: req.user.email, name: req.user.name })
|
||||||
|
return reply.status(201).send(detail)
|
||||||
|
})
|
||||||
|
|
||||||
|
// PATCH /api/events/:id
|
||||||
|
app.patch('/api/events/:id', { preHandler: requireCap('edit') }, async (req, reply) => {
|
||||||
|
const id = parseInt(req.params.id)
|
||||||
|
const b = req.body || {}
|
||||||
|
|
||||||
|
// A system-calendar event (e.g. a bank holiday) isn't user-editable —
|
||||||
|
// enforced here too, not just in the UI, and matching what the CalDAV
|
||||||
|
// PUT handler already does. Check both the event's current calendar and
|
||||||
|
// (if the request tries to move it) the target calendar.
|
||||||
|
const { rows: current } = await pool.query(
|
||||||
|
`SELECT c.is_system FROM events e JOIN calendars c ON c.id = e.calendar_id WHERE e.id = $1 AND e.deleted_at IS NULL`,
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
if (!current.length) return reply.status(404).send({ error: 'Event not found' })
|
||||||
|
if (current[0].is_system) return reply.status(400).send({ error: 'Cannot edit an event on a system calendar' })
|
||||||
|
|
||||||
|
if (b.calendar_id) {
|
||||||
|
const { rows: cal } = await pool.query('SELECT id, is_system FROM calendars WHERE id = $1 AND deleted_at IS NULL', [b.calendar_id])
|
||||||
|
if (!cal.length) return reply.status(400).send({ error: 'Unknown calendar' })
|
||||||
|
if (cal[0].is_system) return reply.status(400).send({ error: 'Cannot move an event onto a system calendar' })
|
||||||
|
}
|
||||||
|
const detail = await updateEvent(pool, id, b, { email: req.user.email, name: req.user.name })
|
||||||
|
if (!detail) return reply.status(404).send({ error: 'Event not found' })
|
||||||
|
return detail
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /api/events/:id — soft delete
|
||||||
|
app.delete('/api/events/:id', { preHandler: requireCap('edit') }, async (req, reply) => {
|
||||||
|
const id = parseInt(req.params.id)
|
||||||
|
const { rows: current } = await pool.query(
|
||||||
|
`SELECT c.is_system FROM events e JOIN calendars c ON c.id = e.calendar_id WHERE e.id = $1 AND e.deleted_at IS NULL`,
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
if (!current.length) return reply.status(404).send({ error: 'Event not found' })
|
||||||
|
if (current[0].is_system) return reply.status(400).send({ error: 'Cannot delete an event on a system calendar' })
|
||||||
|
|
||||||
|
const ok = await deleteEvent(pool, id, { email: req.user.email, name: req.user.name })
|
||||||
|
if (!ok) return reply.status(404).send({ error: 'Event not found' })
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
41
docker-compose.yml
Normal file
41
docker-compose.yml
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
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=calendar
|
||||||
|
- OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}
|
||||||
|
volumes:
|
||||||
|
- uploads_data:/app/uploads
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
args:
|
||||||
|
VITE_HOTEL_NAME: ${VITE_HOTEL_NAME}
|
||||||
|
security_opt:
|
||||||
|
- apparmor=unconfined
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-3080}:80"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
uploads_data:
|
||||||
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
ARG VITE_HOTEL_NAME
|
||||||
|
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html/calendar
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="theme-color" content="#c9a84c" />
|
||||||
|
<title>Calendar</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
56
frontend/nginx.conf
Normal file
56
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
client_max_body_size 12m;
|
||||||
|
|
||||||
|
location /calendar/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 /calendar/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";
|
||||||
|
}
|
||||||
|
|
||||||
|
# CalDAV — WebDAV verbs (PROPFIND/REPORT/MKCALENDAR/PUT/DELETE/GET) proxied straight
|
||||||
|
# through to the backend, same upstream as /calendar/api/ but its own prefix so native
|
||||||
|
# calendar clients (Apple/Google/Outlook) get a clean, stable subscription URL.
|
||||||
|
location /calendar/caldav/ {
|
||||||
|
proxy_pass http://backend:3001/caldav/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
proxy_pass_header Authorization;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /.well-known/caldav {
|
||||||
|
return 301 /calendar/caldav/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /calendar/health {
|
||||||
|
proxy_pass http://backend:3001/health;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* /calendar/.*\.(js|css|png|ico|svg|woff2?)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /calendar/ {
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
try_files $uri /calendar/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = / {
|
||||||
|
return 301 /calendar/;
|
||||||
|
}
|
||||||
|
}
|
||||||
6223
frontend/package-lock.json
generated
Normal file
6223
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "hnf-calendar-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",
|
||||||
|
"vite-plugin-pwa": "^1.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
frontend/src/App.tsx
Normal file
33
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import AuthGate from './components/AuthGate'
|
||||||
|
import { UpdateBanner } from './components/UpdateBanner'
|
||||||
|
import { useVersionCheck } from './hooks/useVersionCheck'
|
||||||
|
import Layout from './components/Layout'
|
||||||
|
import CalendarView from './pages/CalendarView'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import CalendarSettings from './pages/CalendarSettings'
|
||||||
|
import ActivityLog from './pages/ActivityLog'
|
||||||
|
import CalDavSetup from './pages/CalDavSetup'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const updateAvailable = useVersionCheck('/calendar/health')
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BrowserRouter basename="/calendar">
|
||||||
|
<AuthGate>
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<CalendarView />} />
|
||||||
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="/settings" element={<CalendarSettings />} />
|
||||||
|
<Route path="/activity" element={<ActivityLog />} />
|
||||||
|
<Route path="/caldav-setup" element={<CalDavSetup />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
</AuthGate>
|
||||||
|
</BrowserRouter>
|
||||||
|
<UpdateBanner visible={updateAvailable} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
frontend/src/api.ts
Normal file
143
frontend/src/api.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import type {
|
||||||
|
Calendar, EventSummary, EventDetail, EventAttachment,
|
||||||
|
ActivityLogEntry, Department, AuthUser, CaldavCredential, CaldavCredentialCreated,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const BASE = '/calendar/api'
|
||||||
|
|
||||||
|
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
||||||
|
...opts,
|
||||||
|
})
|
||||||
|
if (res.status === 401) {
|
||||||
|
;(window.top ?? window).location.href = '/login'
|
||||||
|
throw new Error('Unauthenticated')
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
|
throw new Error(err.error || `Request failed: ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendars
|
||||||
|
export function fetchCalendars(): Promise<Calendar[]> {
|
||||||
|
return request('/calendars')
|
||||||
|
}
|
||||||
|
export function createCalendar(body: { name: string; color: string }): Promise<Calendar> {
|
||||||
|
return request('/calendars', { method: 'POST', body: JSON.stringify(body) })
|
||||||
|
}
|
||||||
|
export function updateCalendar(id: number, body: { name?: string; color?: string }): Promise<Calendar> {
|
||||||
|
return request(`/calendars/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
|
||||||
|
}
|
||||||
|
export function deleteCalendar(id: number): Promise<{ ok: boolean }> {
|
||||||
|
return request(`/calendars/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events
|
||||||
|
export interface EventFilters {
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
calendar_id?: number
|
||||||
|
department_id?: string
|
||||||
|
mine?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchEvents(filters: EventFilters = {}): Promise<EventSummary[]> {
|
||||||
|
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(`/events${qs ? `?${qs}` : ''}`)
|
||||||
|
}
|
||||||
|
export function fetchEvent(id: number): Promise<EventDetail> {
|
||||||
|
return request(`/events/${id}`)
|
||||||
|
}
|
||||||
|
export interface EventBody {
|
||||||
|
calendar_id: number
|
||||||
|
title: string
|
||||||
|
description?: string | null
|
||||||
|
location?: string | null
|
||||||
|
start_at: string
|
||||||
|
end_at: string
|
||||||
|
all_day: boolean
|
||||||
|
departments?: { id: string; name: string }[]
|
||||||
|
assignees?: { email: string; name: string }[]
|
||||||
|
}
|
||||||
|
export function createEvent(body: EventBody): Promise<EventDetail> {
|
||||||
|
return request('/events', { method: 'POST', body: JSON.stringify(body) })
|
||||||
|
}
|
||||||
|
export function updateEvent(id: number, body: Partial<EventBody>): Promise<EventDetail> {
|
||||||
|
return request(`/events/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
|
||||||
|
}
|
||||||
|
export function deleteEvent(id: number): Promise<{ ok: boolean }> {
|
||||||
|
return request(`/events/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments — multipart, so no JSON content-type header
|
||||||
|
export async function uploadAttachment(eventId: number, file: File): Promise<EventAttachment> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
const res = await fetch(`${BASE}/events/${eventId}/attachments`, { 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 deleteAttachment(id: number): Promise<{ ok: boolean }> {
|
||||||
|
return request(`/attachments/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity log
|
||||||
|
export interface ActivityFilters {
|
||||||
|
calendar_id?: number
|
||||||
|
event_id?: number
|
||||||
|
actor_email?: string
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
export function fetchActivity(filters: ActivityFilters = {}): Promise<ActivityLogEntry[]> {
|
||||||
|
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(`/activity${qs ? `?${qs}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Departments
|
||||||
|
export function fetchDepartments(): Promise<Department[]> {
|
||||||
|
return request('/departments')
|
||||||
|
}
|
||||||
|
export function fetchMyDepartments(): Promise<Department[]> {
|
||||||
|
return request('/me/departments')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
export function fetchMyUpcoming(days = 7): Promise<EventSummary[]> {
|
||||||
|
return request(`/me/upcoming?days=${days}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalDAV credentials
|
||||||
|
export function fetchCaldavCredentials(): Promise<CaldavCredential[]> {
|
||||||
|
return request('/caldav-credentials')
|
||||||
|
}
|
||||||
|
export function createCaldavCredential(label?: string): Promise<CaldavCredentialCreated> {
|
||||||
|
return request('/caldav-credentials', { method: 'POST', body: JSON.stringify({ label }) })
|
||||||
|
}
|
||||||
|
export function deleteCaldavCredential(id: number): Promise<{ ok: boolean }> {
|
||||||
|
return request(`/caldav-credentials/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assignable staff users — served by the central auth service through the nginx auth proxy
|
||||||
|
export async function fetchAssignableUsers(): Promise<AuthUser[]> {
|
||||||
|
const res = await fetch('/calendar/api/auth/users?app=calendar', { credentials: 'include' })
|
||||||
|
if (!res.ok) throw new Error(`Failed to load users: ${res.status}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
76
frontend/src/components/AttachmentList.tsx
Normal file
76
frontend/src/components/AttachmentList.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { Paperclip, Trash2, Upload, Loader2 } from 'lucide-react'
|
||||||
|
import type { EventAttachment } from '../types'
|
||||||
|
import { uploadAttachment, deleteAttachment } from '../api'
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AttachmentList({ eventId, attachments, canEdit, onChange }: {
|
||||||
|
eventId: number
|
||||||
|
attachments: EventAttachment[]
|
||||||
|
canEdit: boolean
|
||||||
|
onChange: (attachments: EventAttachment[]) => void
|
||||||
|
}) {
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setUploading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const att = await uploadAttachment(eventId, file)
|
||||||
|
onChange([...attachments, att])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Upload failed')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
if (fileRef.current) fileRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
if (!confirm('Remove this attachment?')) return
|
||||||
|
await deleteAttachment(id)
|
||||||
|
onChange(attachments.filter(a => a.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
<label>Attachments</label>
|
||||||
|
{attachments.length === 0 && <div className="field-hint">No attachments yet.</div>}
|
||||||
|
<div className="timeline">
|
||||||
|
{attachments.map(att => (
|
||||||
|
<div key={att.id} className="timeline-item">
|
||||||
|
<Paperclip size={14} strokeWidth={1.75} className="timeline-icon" />
|
||||||
|
<div className="timeline-body">
|
||||||
|
<a href={att.url} target="_blank" rel="noreferrer" className="timeline-photo-link">{att.filename}</a>
|
||||||
|
<div className="timeline-meta">{formatSize(att.size_bytes)}</div>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<button type="button" className="btn-ghost-sm btn-ghost-danger" onClick={() => handleDelete(att.id)}>
|
||||||
|
<Trash2 size={13} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
<input ref={fileRef} type="file" style={{ display: 'none' }} onChange={handleFile} />
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => fileRef.current?.click()} disabled={uploading}>
|
||||||
|
{uploading ? <Loader2 size={14} strokeWidth={1.75} className="spin" /> : <Upload size={14} strokeWidth={1.75} />}
|
||||||
|
{uploading ? 'Uploading…' : 'Add attachment'}
|
||||||
|
</button>
|
||||||
|
{error && <div className="field-hint" style={{ color: 'var(--danger)' }}>{error}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
frontend/src/components/AuthGate.tsx
Normal file
45
frontend/src/components/AuthGate.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { useEffect, useState, createContext, useContext } from 'react'
|
||||||
|
import type { User } from '../types'
|
||||||
|
|
||||||
|
interface AuthCtx { user: User }
|
||||||
|
const Ctx = createContext<AuthCtx | null>(null)
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(Ctx)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used inside AuthGate')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/auth/verify?app=calendar', { credentials: 'include' })
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) {
|
||||||
|
;(window.top ?? window).location.href = '/login'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return r.json()
|
||||||
|
})
|
||||||
|
.then(data => { if (data) setUser(data) })
|
||||||
|
.catch(() => { ;(window.top ?? window).location.href = '/login' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
height: '100vh', fontFamily: 'var(--font)', color: 'var(--text-mid)'
|
||||||
|
}}>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Ctx.Provider value={{ user }}>
|
||||||
|
{children}
|
||||||
|
</Ctx.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
frontend/src/components/CalendarToggleList.tsx
Normal file
28
frontend/src/components/CalendarToggleList.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { Calendar } from '../types'
|
||||||
|
|
||||||
|
// Sidebar/panel widget: checkbox + colour dot + name per calendar. Controls a
|
||||||
|
// Set<number> of visible calendar ids lifted from the parent (CalendarView).
|
||||||
|
export default function CalendarToggleList({ calendars, visibleIds, onToggle }: {
|
||||||
|
calendars: Calendar[]
|
||||||
|
visibleIds: Set<number>
|
||||||
|
onToggle: (id: number) => void
|
||||||
|
}) {
|
||||||
|
if (calendars.length === 0) {
|
||||||
|
return <div className="field-hint">No calendars yet.</div>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="cal-sidebar-calendars">
|
||||||
|
{calendars.map(cal => (
|
||||||
|
<label key={cal.id} className="cal-toggle-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visibleIds.has(cal.id)}
|
||||||
|
onChange={() => onToggle(cal.id)}
|
||||||
|
/>
|
||||||
|
<span className="cal-dot" style={{ background: cal.color }} />
|
||||||
|
<span className="cal-toggle-name">{cal.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
344
frontend/src/components/EventForm.tsx
Normal file
344
frontend/src/components/EventForm.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { X, Trash2, ChevronDown, ChevronRight, Lock } from 'lucide-react'
|
||||||
|
import type { Calendar, Department, EventAttachment, AuthUser, ActivityLogEntry } from '../types'
|
||||||
|
import { can } from '../types'
|
||||||
|
import { useAuth } from './AuthGate'
|
||||||
|
import {
|
||||||
|
fetchCalendars, fetchDepartments, fetchAssignableUsers, fetchEvent, fetchActivity,
|
||||||
|
createEvent, updateEvent, deleteEvent,
|
||||||
|
} from '../api'
|
||||||
|
import type { EventBody } from '../api'
|
||||||
|
import AttachmentList from './AttachmentList'
|
||||||
|
import { toISODate, toTimeInput } from '../dateUtils'
|
||||||
|
|
||||||
|
interface Assignee { email: string; name: string }
|
||||||
|
|
||||||
|
interface EventFormProps {
|
||||||
|
eventId?: number
|
||||||
|
initialDate?: Date
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventForm({ eventId, initialDate, onClose, onSaved }: EventFormProps) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const editing = eventId != null
|
||||||
|
const permitted = editing ? can(user, 'edit') : can(user, 'create')
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(editing)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [calendars, setCalendars] = useState<Calendar[]>([])
|
||||||
|
const [departments, setDepartments] = useState<Department[]>([])
|
||||||
|
const [users, setUsers] = useState<AuthUser[]>([])
|
||||||
|
|
||||||
|
const baseStart = initialDate ?? new Date()
|
||||||
|
const baseEnd = new Date(baseStart.getTime() + 60 * 60 * 1000)
|
||||||
|
|
||||||
|
const [calendarId, setCalendarId] = useState<number | null>(null)
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [location, setLocation] = useState('')
|
||||||
|
const [allDay, setAllDay] = useState(false)
|
||||||
|
const [startDate, setStartDate] = useState(toISODate(baseStart))
|
||||||
|
const [startTime, setStartTime] = useState(toTimeInput(baseStart))
|
||||||
|
const [endDate, setEndDate] = useState(toISODate(baseEnd))
|
||||||
|
const [endTime, setEndTime] = useState(toTimeInput(baseEnd))
|
||||||
|
const [selectedDepts, setSelectedDepts] = useState<Department[]>([])
|
||||||
|
const [selectedAssignees, setSelectedAssignees] = useState<Assignee[]>([])
|
||||||
|
const [attachments, setAttachments] = useState<EventAttachment[]>([])
|
||||||
|
const [isSystem, setIsSystem] = useState(false)
|
||||||
|
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
|
const [history, setHistory] = useState<ActivityLogEntry[] | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCalendars().then(setCalendars).catch(() => {})
|
||||||
|
fetchDepartments().then(setDepartments).catch(() => {})
|
||||||
|
fetchAssignableUsers().then(setUsers).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Default the calendar select to the first user-editable calendar once loaded (create mode only).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing && calendarId === null && calendars.length > 0) {
|
||||||
|
const first = calendars.find(c => !c.is_system)
|
||||||
|
if (first) setCalendarId(first.id)
|
||||||
|
}
|
||||||
|
}, [calendars, editing, calendarId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing || !eventId) return
|
||||||
|
setLoading(true)
|
||||||
|
fetchEvent(eventId)
|
||||||
|
.then(ev => {
|
||||||
|
setCalendarId(ev.calendar_id)
|
||||||
|
setTitle(ev.title)
|
||||||
|
setDescription(ev.description ?? '')
|
||||||
|
setLocation(ev.location ?? '')
|
||||||
|
setAllDay(ev.all_day)
|
||||||
|
const s = new Date(ev.start_at)
|
||||||
|
const e = new Date(ev.end_at)
|
||||||
|
setStartDate(toISODate(s))
|
||||||
|
setStartTime(toTimeInput(s))
|
||||||
|
setEndDate(toISODate(e))
|
||||||
|
setEndTime(toTimeInput(e))
|
||||||
|
setSelectedDepts(ev.departments)
|
||||||
|
setSelectedAssignees(ev.assignees)
|
||||||
|
setAttachments(ev.attachments)
|
||||||
|
setIsSystem(ev.calendar.is_system)
|
||||||
|
})
|
||||||
|
.catch(err => setError(err instanceof Error ? err.message : 'Failed to load event'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [eventId, editing])
|
||||||
|
|
||||||
|
function toggleDept(dept: Department) {
|
||||||
|
setSelectedDepts(prev =>
|
||||||
|
prev.some(d => d.id === dept.id) ? prev.filter(d => d.id !== dept.id) : [...prev, dept]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAssignee(u: AuthUser) {
|
||||||
|
setSelectedAssignees(prev =>
|
||||||
|
prev.some(a => a.email === u.email) ? prev.filter(a => a.email !== u.email) : [...prev, { email: u.email, name: u.name }]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!calendarId) { setError('Choose a calendar'); return }
|
||||||
|
if (!title.trim()) { setError('Title is required'); return }
|
||||||
|
|
||||||
|
const start = allDay ? new Date(`${startDate}T00:00:00`) : new Date(`${startDate}T${startTime}:00`)
|
||||||
|
const end = allDay ? new Date(`${endDate}T23:59:00`) : new Date(`${endDate}T${endTime}:00`)
|
||||||
|
if (end.getTime() < start.getTime()) { setError('End must be after start'); return }
|
||||||
|
|
||||||
|
const body: EventBody = {
|
||||||
|
calendar_id: calendarId,
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
location: location.trim() || null,
|
||||||
|
start_at: start.toISOString(),
|
||||||
|
end_at: end.toISOString(),
|
||||||
|
all_day: allDay,
|
||||||
|
departments: selectedDepts,
|
||||||
|
assignees: selectedAssignees,
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
if (editing && eventId) await updateEvent(eventId, body)
|
||||||
|
else await createEvent(body)
|
||||||
|
onSaved()
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Save failed')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!eventId) return
|
||||||
|
if (!confirm('Delete this event? This cannot be undone.')) return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await deleteEvent(eventId)
|
||||||
|
onSaved()
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Delete failed')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHistory() {
|
||||||
|
const next = !historyOpen
|
||||||
|
setHistoryOpen(next)
|
||||||
|
if (next && history === null && eventId) {
|
||||||
|
fetchActivity({ event_id: eventId }).then(setHistory).catch(() => setHistory([]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editableCalendars = calendars.filter(c => !c.is_system)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>{editing ? 'Edit event' : 'New event'}</h2>
|
||||||
|
<button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="empty-state">Loading…</div>
|
||||||
|
) : isSystem ? (
|
||||||
|
<div>
|
||||||
|
<div className="field-hint" style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12 }}>
|
||||||
|
<Lock size={13} strokeWidth={1.75} />
|
||||||
|
This event belongs to a read-only system calendar and cannot be edited.
|
||||||
|
</div>
|
||||||
|
<div className="field"><label>Title</label><div>{title}</div></div>
|
||||||
|
{location && <div className="field"><label>Location</label><div>{location}</div></div>}
|
||||||
|
{description && <div className="field"><label>Description</label><div style={{ whiteSpace: 'pre-wrap' }}>{description}</div></div>}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn" onClick={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="field">
|
||||||
|
<label>Title</label>
|
||||||
|
<input type="text" value={title} onChange={e => setTitle(e.target.value)} disabled={!permitted} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Calendar</label>
|
||||||
|
<select value={calendarId ?? ''} onChange={e => setCalendarId(Number(e.target.value))} disabled={!permitted}>
|
||||||
|
<option value="" disabled>Choose a calendar…</option>
|
||||||
|
{editableCalendars.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea value={description} onChange={e => setDescription(e.target.value)} disabled={!permitted} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Location</label>
|
||||||
|
<input type="text" value={location} onChange={e => setLocation(e.target.value)} disabled={!permitted} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="field-check" style={{ marginBottom: 12 }}>
|
||||||
|
<input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} disabled={!permitted} />
|
||||||
|
All day
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label>Start date</label>
|
||||||
|
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} disabled={!permitted} required />
|
||||||
|
</div>
|
||||||
|
{!allDay && (
|
||||||
|
<div className="field">
|
||||||
|
<label>Start time</label>
|
||||||
|
<input type="time" value={startTime} onChange={e => setStartTime(e.target.value)} disabled={!permitted} required />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label>End date</label>
|
||||||
|
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} disabled={!permitted} required />
|
||||||
|
</div>
|
||||||
|
{!allDay && (
|
||||||
|
<div className="field">
|
||||||
|
<label>End time</label>
|
||||||
|
<input type="time" value={endTime} onChange={e => setEndTime(e.target.value)} disabled={!permitted} required />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Departments</label>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{departments.map(dept => (
|
||||||
|
<label key={dept.id} className="field-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedDepts.some(d => d.id === dept.id)}
|
||||||
|
onChange={() => toggleDept(dept)}
|
||||||
|
disabled={!permitted}
|
||||||
|
/>
|
||||||
|
{dept.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{departments.length === 0 && <span className="field-hint">No departments configured.</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Assignees</label>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{users.map(u => (
|
||||||
|
<label key={u.email} className="field-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedAssignees.some(a => a.email === u.email)}
|
||||||
|
onChange={() => toggleAssignee(u)}
|
||||||
|
disabled={!permitted}
|
||||||
|
/>
|
||||||
|
{u.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{users.length === 0 && <span className="field-hint">No staff available.</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && eventId && (
|
||||||
|
<AttachmentList
|
||||||
|
eventId={eventId}
|
||||||
|
attachments={attachments}
|
||||||
|
canEdit={permitted}
|
||||||
|
onChange={setAttachments}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!editing && (
|
||||||
|
<div className="field-hint" style={{ marginBottom: 12 }}>Save the event first to add attachments.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="filter-section-header"
|
||||||
|
onClick={toggleHistory}
|
||||||
|
style={{ marginBottom: historyOpen ? 8 : 0 }}
|
||||||
|
>
|
||||||
|
{historyOpen ? <ChevronDown size={14} strokeWidth={1.75} /> : <ChevronRight size={14} strokeWidth={1.75} />}
|
||||||
|
<span className="filter-section-label">History</span>
|
||||||
|
</div>
|
||||||
|
{historyOpen && (
|
||||||
|
<div className="timeline">
|
||||||
|
{history === null && <div className="field-hint">Loading…</div>}
|
||||||
|
{history && history.length === 0 && <div className="field-hint">No activity recorded.</div>}
|
||||||
|
{history && history.map(h => (
|
||||||
|
<div key={h.id} className="timeline-item">
|
||||||
|
<div className="timeline-body">
|
||||||
|
<div className="timeline-note">{h.summary}</div>
|
||||||
|
<div className="timeline-meta">{h.actor_name} · {new Date(h.created_at).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="modal-footer-actions">
|
||||||
|
{editing && permitted && (
|
||||||
|
<button type="button" className="btn btn-danger" onClick={handleDelete} disabled={saving} style={{ marginRight: 'auto' }}>
|
||||||
|
<Trash2 size={14} strokeWidth={1.75} />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||||
|
{permitted && (
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
frontend/src/components/Layout.tsx
Normal file
74
frontend/src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
|
import { CalendarDays, LayoutDashboard, Settings, History, Smartphone, Menu, LogOut } from 'lucide-react'
|
||||||
|
import { useAuth } from './AuthGate'
|
||||||
|
import { can } from '../types'
|
||||||
|
|
||||||
|
const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ to: '/', label: 'Calendar', icon: CalendarDays, cap: 'view' },
|
||||||
|
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, cap: 'view' },
|
||||||
|
{ to: '/settings', label: 'Settings', icon: Settings, cap: 'manage_calendars' },
|
||||||
|
{ to: '/activity', label: 'Activity Log', icon: History, cap: 'admin' },
|
||||||
|
{ to: '/caldav-setup', label: 'Phone Sync', icon: Smartphone, cap: 'view' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const items = NAV.filter(n => can(user, n.cap))
|
||||||
|
const location = useLocation()
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await fetch('/calendar/api/auth/logout', { method: 'POST', credentials: 'include' })
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { setMenuOpen(false) }, [location.pathname])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`app-shell${menuOpen ? ' menu-open' : ''}`}>
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-logo">
|
||||||
|
<CalendarDays size={18} strokeWidth={1.75} />
|
||||||
|
Calendar
|
||||||
|
</div>
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
{items.map(({ to, label, icon: Icon }) => (
|
||||||
|
<NavLink key={to} to={to} end={to === '/'} className={({ isActive }) => isActive ? 'active' : ''}>
|
||||||
|
<Icon {...ICON_PROPS} />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="sidebar-user" style={{ whiteSpace: 'normal' }}>
|
||||||
|
<div style={{ fontWeight: 600, color: 'var(--text)', fontSize: '12px', marginBottom: '2px' }}>{user.name}</div>
|
||||||
|
<div style={{ fontSize: '11px', marginBottom: '8px' }}>{user.email}</div>
|
||||||
|
<button onClick={logout} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '6px',
|
||||||
|
background: 'none', border: 'none', color: 'inherit',
|
||||||
|
fontSize: '12px', padding: 0, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<LogOut size={13} strokeWidth={1.75} />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{menuOpen && <div className="menu-backdrop" onClick={() => setMenuOpen(false)} />}
|
||||||
|
|
||||||
|
<header className="top-bar">
|
||||||
|
<button className="top-bar-burger" onClick={() => setMenuOpen(o => !o)}>
|
||||||
|
<Menu size={20} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
<CalendarDays size={18} strokeWidth={1.75} color="var(--gold)" />
|
||||||
|
<span className="top-bar-title">Calendar</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="page-content">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
frontend/src/components/UpdateBanner.tsx
Normal file
44
frontend/src/components/UpdateBanner.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
|
export function UpdateBanner({ visible }: { visible: boolean }) {
|
||||||
|
if (!visible) return null
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
background: 'var(--sidebar)',
|
||||||
|
color: 'var(--text-light)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
fontSize: '14px',
|
||||||
|
boxShadow: '0 -2px 8px rgba(0,0,0,0.3)',
|
||||||
|
}}>
|
||||||
|
<span>A new version is available.</span>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
background: 'var(--accent)',
|
||||||
|
color: 'var(--sidebar)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} strokeWidth={1.75} />
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
frontend/src/components/ViewSwitcher.tsx
Normal file
28
frontend/src/components/ViewSwitcher.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
export type CalendarViewKey = 'month' | 'week' | 'day' | 'list'
|
||||||
|
|
||||||
|
const VIEWS: { key: CalendarViewKey; label: string }[] = [
|
||||||
|
{ key: 'month', label: 'Month' },
|
||||||
|
{ key: 'week', label: 'Week' },
|
||||||
|
{ key: 'day', label: 'Day' },
|
||||||
|
{ key: 'list', label: 'List' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ViewSwitcher({ view, onChange }: {
|
||||||
|
view: CalendarViewKey
|
||||||
|
onChange: (v: CalendarViewKey) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="cal-view-switcher chip-bar">
|
||||||
|
{VIEWS.map(v => (
|
||||||
|
<button
|
||||||
|
key={v.key}
|
||||||
|
type="button"
|
||||||
|
className={`chip ${view === v.key ? 'active' : ''}`}
|
||||||
|
onClick={() => onChange(v.key)}
|
||||||
|
>
|
||||||
|
{v.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
frontend/src/components/views/AgendaList.tsx
Normal file
75
frontend/src/components/views/AgendaList.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import type { EventSummary } from '../../types'
|
||||||
|
import { toISODate, formatDayHeader, formatTime } from '../../dateUtils'
|
||||||
|
|
||||||
|
// Doubles as a simple client-side search box (filters by title/location —
|
||||||
|
// EventSummary carries no description field, only EventDetail does).
|
||||||
|
export default function AgendaList({ events, onSelectEvent }: {
|
||||||
|
events: EventSummary[]
|
||||||
|
date: Date
|
||||||
|
onSelectDate?: (d: Date) => void
|
||||||
|
onSelectEvent: (id: number) => void
|
||||||
|
}) {
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const needle = q.trim().toLowerCase()
|
||||||
|
const list = needle
|
||||||
|
? events.filter(ev => ev.title.toLowerCase().includes(needle) || (ev.location ?? '').toLowerCase().includes(needle))
|
||||||
|
: events
|
||||||
|
return [...list].sort((a, b) => a.start_at.localeCompare(b.start_at))
|
||||||
|
}, [events, q])
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const map = new Map<string, EventSummary[]>()
|
||||||
|
for (const ev of filtered) {
|
||||||
|
const key = toISODate(new Date(ev.start_at))
|
||||||
|
if (!map.has(key)) map.set(key, [])
|
||||||
|
map.get(key)!.push(ev)
|
||||||
|
}
|
||||||
|
return [...map.entries()]
|
||||||
|
}, [filtered])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="field" style={{ maxWidth: 320 }}>
|
||||||
|
<label>Search</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Search size={14} strokeWidth={1.75} style={{ position: 'absolute', left: 10, top: 10, color: 'var(--text-mid)' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={q}
|
||||||
|
onChange={e => setQ(e.target.value)}
|
||||||
|
placeholder="Filter by title or location…"
|
||||||
|
style={{ paddingLeft: 30 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.length === 0 && <div className="empty-state">No events found.</div>}
|
||||||
|
|
||||||
|
{groups.map(([dayKey, dayEvents]) => (
|
||||||
|
<div key={dayKey}>
|
||||||
|
<div className="section-title">{formatDayHeader(new Date(dayKey))}</div>
|
||||||
|
{dayEvents.map(ev => (
|
||||||
|
<div key={ev.id} className="cal-agenda-item" onClick={() => onSelectEvent(ev.id)}>
|
||||||
|
<div className="cal-agenda-date">{ev.all_day ? 'All day' : formatTime(ev.start_at)}</div>
|
||||||
|
<div className="cal-agenda-main">
|
||||||
|
<div className="cal-agenda-title">
|
||||||
|
<span className="cal-dot" style={{ background: ev.calendar_color }} />
|
||||||
|
{ev.title}
|
||||||
|
</div>
|
||||||
|
<div className="cal-agenda-meta">
|
||||||
|
{ev.location && <span>{ev.location}</span>}
|
||||||
|
{ev.department_names.length > 0 && <span>{ev.department_names.join(', ')}</span>}
|
||||||
|
{ev.assignee_names.length > 0 && <span>{ev.assignee_names.join(', ')}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
frontend/src/components/views/DayGrid.tsx
Normal file
66
frontend/src/components/views/DayGrid.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import type { EventSummary } from '../../types'
|
||||||
|
import { eventOccursOnDay, formatTime, formatDateLabel, HOURS, HOUR_PX, timedLayout } from '../../dateUtils'
|
||||||
|
|
||||||
|
export default function DayGrid({ events, date, onSelectDate, onSelectEvent }: {
|
||||||
|
events: EventSummary[]
|
||||||
|
date: Date
|
||||||
|
onSelectDate?: (d: Date) => void
|
||||||
|
onSelectEvent: (id: number) => void
|
||||||
|
}) {
|
||||||
|
const allDayEvents = events.filter(ev => ev.all_day && eventOccursOnDay(ev, date))
|
||||||
|
const timedEvents = events.filter(ev => !ev.all_day && eventOccursOnDay(ev, date))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cal-week-grid cal-day-view">
|
||||||
|
<div className="cal-week-head-cell" />
|
||||||
|
<div
|
||||||
|
className="cal-week-head-cell today"
|
||||||
|
onClick={() => onSelectDate?.(date)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{formatDateLabel(date)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div />
|
||||||
|
<div style={{ borderLeft: '1px solid var(--card-border)', padding: '3px' }}>
|
||||||
|
{allDayEvents.map(ev => (
|
||||||
|
<span
|
||||||
|
key={ev.id}
|
||||||
|
className="cal-event-chip"
|
||||||
|
style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }}
|
||||||
|
onClick={() => onSelectEvent(ev.id)}
|
||||||
|
>
|
||||||
|
{ev.title}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cal-time-gutter">
|
||||||
|
{HOURS.map(h => (
|
||||||
|
<div key={h} className="cal-time-row">{h === 0 ? '' : `${h}:00`}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="cal-day-col"
|
||||||
|
style={{ height: HOURS.length * HOUR_PX }}
|
||||||
|
onClick={() => onSelectDate?.(date)}
|
||||||
|
>
|
||||||
|
{HOURS.map(h => <div key={h} className="cal-day-col-slot" />)}
|
||||||
|
{timedEvents.map(ev => {
|
||||||
|
const { top, height } = timedLayout(ev, date)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ev.id}
|
||||||
|
className="cal-week-event"
|
||||||
|
style={{ top, height, background: ev.calendar_color, color: '#fff' }}
|
||||||
|
onClick={e => { e.stopPropagation(); onSelectEvent(ev.id) }}
|
||||||
|
title={ev.title}
|
||||||
|
>
|
||||||
|
{formatTime(ev.start_at)} {ev.title}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
frontend/src/components/views/MonthGrid.tsx
Normal file
51
frontend/src/components/views/MonthGrid.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import type { EventSummary } from '../../types'
|
||||||
|
import { monthGridDays, isSameDay, eventOccursOnDay, formatTime, WEEKDAY_LABELS } from '../../dateUtils'
|
||||||
|
|
||||||
|
const MAX_VISIBLE = 3
|
||||||
|
|
||||||
|
export default function MonthGrid({ events, date, onSelectDate, onSelectEvent }: {
|
||||||
|
events: EventSummary[]
|
||||||
|
date: Date
|
||||||
|
onSelectDate?: (d: Date) => void
|
||||||
|
onSelectEvent: (id: number) => void
|
||||||
|
}) {
|
||||||
|
const days = monthGridDays(date)
|
||||||
|
const today = new Date()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cal-grid">
|
||||||
|
{WEEKDAY_LABELS.map(d => (
|
||||||
|
<div key={d} className="cal-day-header">{d}</div>
|
||||||
|
))}
|
||||||
|
{days.map(day => {
|
||||||
|
const dayEvents = events
|
||||||
|
.filter(ev => eventOccursOnDay(ev, day))
|
||||||
|
.sort((a, b) => a.start_at.localeCompare(b.start_at))
|
||||||
|
const cls = [
|
||||||
|
'cal-day',
|
||||||
|
day.getMonth() !== date.getMonth() ? 'other-month' : '',
|
||||||
|
isSameDay(day, today) ? 'today' : '',
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
return (
|
||||||
|
<div key={day.toISOString()} className={cls} onClick={() => onSelectDate?.(day)}>
|
||||||
|
<div className="cal-day-num">{day.getDate()}</div>
|
||||||
|
{dayEvents.slice(0, MAX_VISIBLE).map(ev => (
|
||||||
|
<span
|
||||||
|
key={ev.id}
|
||||||
|
className="cal-event-chip"
|
||||||
|
style={{ background: ev.calendar_color, color: '#fff' }}
|
||||||
|
title={ev.title}
|
||||||
|
onClick={e => { e.stopPropagation(); onSelectEvent(ev.id) }}
|
||||||
|
>
|
||||||
|
{!ev.all_day && `${formatTime(ev.start_at)} `}{ev.title}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{dayEvents.length > MAX_VISIBLE && (
|
||||||
|
<span className="cal-event-more">+{dayEvents.length - MAX_VISIBLE} more</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
frontend/src/components/views/WeekGrid.tsx
Normal file
81
frontend/src/components/views/WeekGrid.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import type { EventSummary } from '../../types'
|
||||||
|
import {
|
||||||
|
startOfWeek, addDays, isSameDay, eventOccursOnDay,
|
||||||
|
formatDayHeader, formatTime, HOURS, HOUR_PX, timedLayout,
|
||||||
|
} from '../../dateUtils'
|
||||||
|
|
||||||
|
export default function WeekGrid({ events, date, onSelectDate, onSelectEvent }: {
|
||||||
|
events: EventSummary[]
|
||||||
|
date: Date
|
||||||
|
onSelectDate?: (d: Date) => void
|
||||||
|
onSelectEvent: (id: number) => void
|
||||||
|
}) {
|
||||||
|
const weekStart = startOfWeek(date)
|
||||||
|
const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
|
||||||
|
const today = new Date()
|
||||||
|
|
||||||
|
const allDayByDay = days.map(day => events.filter(ev => ev.all_day && eventOccursOnDay(ev, day)))
|
||||||
|
const timedByDay = days.map(day => events.filter(ev => !ev.all_day && eventOccursOnDay(ev, day)))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cal-week-grid">
|
||||||
|
<div className="cal-week-head-cell" />
|
||||||
|
{days.map(day => (
|
||||||
|
<div
|
||||||
|
key={day.toISOString()}
|
||||||
|
className={`cal-week-head-cell${isSameDay(day, today) ? ' today' : ''}`}
|
||||||
|
onClick={() => onSelectDate?.(day)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{formatDayHeader(day)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div />
|
||||||
|
{days.map((day, i) => (
|
||||||
|
<div key={`allday-${day.toISOString()}`} style={{ borderLeft: '1px solid var(--card-border)', padding: '3px' }}>
|
||||||
|
{allDayByDay[i].map(ev => (
|
||||||
|
<span
|
||||||
|
key={ev.id}
|
||||||
|
className="cal-event-chip"
|
||||||
|
style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }}
|
||||||
|
onClick={() => onSelectEvent(ev.id)}
|
||||||
|
>
|
||||||
|
{ev.title}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="cal-time-gutter">
|
||||||
|
{HOURS.map(h => (
|
||||||
|
<div key={h} className="cal-time-row">{h === 0 ? '' : `${h}:00`}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{days.map((day, i) => (
|
||||||
|
<div
|
||||||
|
key={`col-${day.toISOString()}`}
|
||||||
|
className="cal-day-col"
|
||||||
|
style={{ height: HOURS.length * HOUR_PX }}
|
||||||
|
onClick={() => onSelectDate?.(day)}
|
||||||
|
>
|
||||||
|
{HOURS.map(h => <div key={h} className="cal-day-col-slot" />)}
|
||||||
|
{timedByDay[i].map(ev => {
|
||||||
|
const { top, height } = timedLayout(ev, day)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ev.id}
|
||||||
|
className="cal-week-event"
|
||||||
|
style={{ top, height, background: ev.calendar_color, color: '#fff' }}
|
||||||
|
onClick={e => { e.stopPropagation(); onSelectEvent(ev.id) }}
|
||||||
|
title={ev.title}
|
||||||
|
>
|
||||||
|
{formatTime(ev.start_at)} {ev.title}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
frontend/src/dateUtils.ts
Normal file
114
frontend/src/dateUtils.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
// Plain-JS date arithmetic shared by the calendar grid views — no external
|
||||||
|
// calendar library. Weeks start Monday to match UK hotel-ops convention.
|
||||||
|
|
||||||
|
export const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||||
|
export const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||||||
|
export const HOUR_PX = 48
|
||||||
|
|
||||||
|
export function startOfDay(d: Date): Date {
|
||||||
|
const r = new Date(d)
|
||||||
|
r.setHours(0, 0, 0, 0)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endOfDay(d: Date): Date {
|
||||||
|
const r = new Date(d)
|
||||||
|
r.setHours(23, 59, 59, 999)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(d: Date, n: number): Date {
|
||||||
|
const r = new Date(d)
|
||||||
|
r.setDate(r.getDate() + n)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addMonths(d: Date, n: number): Date {
|
||||||
|
const r = new Date(d)
|
||||||
|
r.setMonth(r.getMonth() + n)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSameDay(a: Date, b: Date): boolean {
|
||||||
|
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfWeek(d: Date): Date {
|
||||||
|
const r = startOfDay(d)
|
||||||
|
const day = r.getDay() // 0=Sun..6=Sat
|
||||||
|
const diff = day === 0 ? -6 : 1 - day
|
||||||
|
r.setDate(r.getDate() + diff)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toISODate(d: Date): string {
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toTimeInput(d: Date): string {
|
||||||
|
const h = String(d.getHours()).padStart(2, '0')
|
||||||
|
const m = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
return `${h}:${m}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monthGridDays(date: Date): Date[] {
|
||||||
|
const firstOfMonth = new Date(date.getFullYear(), date.getMonth(), 1)
|
||||||
|
const gridStart = startOfWeek(firstOfMonth)
|
||||||
|
return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i))
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Span { start_at: string; end_at: string }
|
||||||
|
|
||||||
|
export function eventSpan(ev: Span): { start: Date; end: Date } {
|
||||||
|
return { start: new Date(ev.start_at), end: new Date(ev.end_at) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventOccursOnDay(ev: Span, day: Date): boolean {
|
||||||
|
const { start, end } = eventSpan(ev)
|
||||||
|
const dayStart = startOfDay(day).getTime()
|
||||||
|
const dayEnd = endOfDay(day).getTime()
|
||||||
|
return start.getTime() <= dayEnd && end.getTime() >= dayStart
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minutesSinceMidnight(d: Date): number {
|
||||||
|
return d.getHours() * 60 + d.getMinutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical position/height (in px) for a timed event rendered within a single
|
||||||
|
// day column, clamped to that day's bounds (handles events that span midnight).
|
||||||
|
export function timedLayout(ev: Span, day: Date): { top: number; height: number } {
|
||||||
|
const dayStart = startOfDay(day)
|
||||||
|
const dayEnd = endOfDay(day)
|
||||||
|
const { start, end } = eventSpan(ev)
|
||||||
|
const clampedStart = start < dayStart ? dayStart : start
|
||||||
|
const clampedEnd = end > dayEnd ? dayEnd : end
|
||||||
|
const top = (minutesSinceMidnight(clampedStart) / 60) * HOUR_PX
|
||||||
|
const durationMin = Math.max(20, (clampedEnd.getTime() - clampedStart.getTime()) / 60000)
|
||||||
|
const height = (durationMin / 60) * HOUR_PX
|
||||||
|
return { top, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDayHeader(d: Date): string {
|
||||||
|
return d.toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMonthLabel(d: Date): string {
|
||||||
|
return d.toLocaleDateString([], { month: 'long', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWeekLabel(d: Date): string {
|
||||||
|
const start = startOfWeek(d)
|
||||||
|
const end = addDays(start, 6)
|
||||||
|
return `${start.toLocaleDateString([], { day: 'numeric', month: 'short' })} – ${end.toLocaleDateString([], { day: 'numeric', month: 'short', year: 'numeric' })}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateLabel(d: Date): string {
|
||||||
|
return d.toLocaleDateString([], { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
|
}
|
||||||
43
frontend/src/hooks/useVersionCheck.ts
Normal file
43
frontend/src/hooks/useVersionCheck.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const POLL_MS = 2 * 60 * 1000
|
||||||
|
|
||||||
|
export function useVersionCheck(healthUrl: string) {
|
||||||
|
const [updateAvailable, setUpdateAvailable] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let seenVersion: string | null = null
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(healthUrl, { cache: 'no-store' })
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
const v: string | undefined = data.version
|
||||||
|
if (!v) return
|
||||||
|
if (seenVersion === null) {
|
||||||
|
seenVersion = v
|
||||||
|
} else if (v !== seenVersion) {
|
||||||
|
setUpdateAvailable(true)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// network error — skip silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check()
|
||||||
|
const interval = setInterval(check, POLL_MS)
|
||||||
|
|
||||||
|
function onVisible() {
|
||||||
|
if (document.visibilityState === 'visible') check()
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', onVisible)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval)
|
||||||
|
document.removeEventListener('visibilitychange', onVisible)
|
||||||
|
}
|
||||||
|
}, [healthUrl])
|
||||||
|
|
||||||
|
return updateAvailable
|
||||||
|
}
|
||||||
601
frontend/src/index.css
Normal file
601
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,601 @@
|
||||||
|
/* Stack design system tokens — include verbatim in every app */
|
||||||
|
:root {
|
||||||
|
--navy: #1a1a2e;
|
||||||
|
--navy-dark: #0f0f20;
|
||||||
|
--gold: #c9a84c;
|
||||||
|
--gold-light: #e8c96d;
|
||||||
|
--surface: rgba(255,255,255,0.07);
|
||||||
|
--surface-2: rgba(255,255,255,0.08);
|
||||||
|
--text: rgba(255,255,255,0.88);
|
||||||
|
--text-muted: rgba(255,255,255,0.48);
|
||||||
|
--body-bg: #f4f5f7;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--card-border: #e4e8ee;
|
||||||
|
--text-dark: #1e293b;
|
||||||
|
--text-mid: #64748b;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
|
||||||
|
--danger: #dc2626;
|
||||||
|
--radius: 10px;
|
||||||
|
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
body { background: var(--body-bg); color: var(--text-dark); font-family: var(--font); }
|
||||||
|
|
||||||
|
/* Layout tokens — calendar has no app-specific accent, it uses --gold/--gold-light directly */
|
||||||
|
:root {
|
||||||
|
--danger-bg: #fef2f2;
|
||||||
|
--warn-bg: #fffbeb;
|
||||||
|
--ok-bg: #f0fdf4;
|
||||||
|
|
||||||
|
--sidebar-w: 240px;
|
||||||
|
--topbar-h: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html, body, #root { height: 100%; margin: 0; font-size: 14px; }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--card-border); border-radius: 2px; }
|
||||||
|
|
||||||
|
/* ── App shell ─────────────────────────────────────────────── */
|
||||||
|
.app-shell { display: flex; height: 100vh; overflow: hidden; }
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-w);
|
||||||
|
background: var(--navy);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.sidebar-logo {
|
||||||
|
padding: 20px 16px 12px;
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.sidebar-logo svg { opacity: .8; }
|
||||||
|
.sidebar-nav { flex: 1; padding: 8px 0; }
|
||||||
|
.sidebar-nav a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13.5px;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.sidebar-nav a:hover { background: var(--surface); color: var(--text); }
|
||||||
|
.sidebar-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); }
|
||||||
|
.sidebar-user {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--surface-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
display: none;
|
||||||
|
height: var(--topbar-h);
|
||||||
|
background: var(--navy);
|
||||||
|
color: var(--text);
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.top-bar-title { flex: 1; font-size: 15px; font-weight: 600; color: var(--gold); }
|
||||||
|
.top-bar-nav { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; }
|
||||||
|
.top-bar-nav::-webkit-scrollbar { display: none; }
|
||||||
|
.top-bar-nav a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.top-bar-nav a.active { color: var(--gold); }
|
||||||
|
|
||||||
|
.page-content { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; bottom: 0;
|
||||||
|
z-index: 200;
|
||||||
|
transform: translateX(calc(-1 * var(--sidebar-w)));
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
.app-shell.menu-open .sidebar { transform: translateX(0); }
|
||||||
|
.top-bar { display: flex; }
|
||||||
|
.app-shell { flex-direction: column; }
|
||||||
|
.field-row { flex-direction: column; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
z-index: 199;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar-burger {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page chrome ───────────────────────────────────────────── */
|
||||||
|
.page { padding: 20px; max-width: 1100px; width: 100%; margin: 0 auto; }
|
||||||
|
.page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||||
|
.page-header h1 { font-size: 18px; margin: 0; flex: 1; }
|
||||||
|
|
||||||
|
/* ── Buttons ───────────────────────────────────────────────── */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-dark);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font);
|
||||||
|
transition: background .12s, border-color .12s;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--text-mid); }
|
||||||
|
.btn:disabled { opacity: .5; cursor: default; }
|
||||||
|
.btn-primary { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
|
||||||
|
.btn-primary:hover { background: var(--gold-light); border-color: var(--gold-light); }
|
||||||
|
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||||
|
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 8px; }
|
||||||
|
|
||||||
|
/* ── Forms ─────────────────────────────────────────────────── */
|
||||||
|
.field { margin-bottom: 12px; }
|
||||||
|
.field label { display: block; font-size: 12px; font-weight: 600; color: var(--text-mid); margin-bottom: 4px; }
|
||||||
|
.field input[type="text"], .field input[type="email"], .field input[type="date"],
|
||||||
|
.field input[type="time"], .field input[type="datetime-local"],
|
||||||
|
.field input[type="number"], .field select, .field textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: var(--font);
|
||||||
|
color: var(--text-dark);
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
.field textarea { min-height: 72px; resize: vertical; }
|
||||||
|
.field-row { display: flex; gap: 12px; }
|
||||||
|
.field-row > .field { flex: 1; }
|
||||||
|
.field-check { display: flex; align-items: center; gap: 8px; font-size: 13.5px; cursor: pointer; }
|
||||||
|
.field-check input { width: 16px; height: 16px; accent-color: var(--gold); }
|
||||||
|
.field-hint { font-size: 11.5px; color: var(--text-mid); margin-top: 3px; }
|
||||||
|
|
||||||
|
/* ── Cards & lists ─────────────────────────────────────────── */
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow .12s;
|
||||||
|
}
|
||||||
|
.task-card:hover { box-shadow: var(--shadow-md); }
|
||||||
|
.task-card-main { flex: 1; min-width: 0; }
|
||||||
|
.task-card-title { font-weight: 600; font-size: 14px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.task-card-meta { font-size: 12px; color: var(--text-mid); display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.task-card-side { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ── Badges ────────────────────────────────────────────────── */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge-outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
color: var(--text-mid);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Filter chips ──────────────────────────────────────────── */
|
||||||
|
.filter-row { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 4px; }
|
||||||
|
.filter-row .filter-section { flex: 1; min-width: 0; margin-bottom: 0; }
|
||||||
|
.filter-row .sort-chip { flex-shrink: 0; align-self: center; white-space: nowrap; }
|
||||||
|
.filter-section { margin-bottom: 10px; }
|
||||||
|
.filter-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-mid);
|
||||||
|
transition: border-color .12s;
|
||||||
|
}
|
||||||
|
.filter-section-header:hover { border-color: var(--gold); }
|
||||||
|
.filter-section-label { font-weight: 600; color: var(--text-dark); white-space: nowrap; }
|
||||||
|
.filter-section-summary { flex: 1; color: var(--gold); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.filter-section-summary.muted { color: var(--text-mid); font-weight: 400; }
|
||||||
|
.filter-section-chevron { flex-shrink: 0; transition: transform .18s; }
|
||||||
|
.filter-section-chevron.open { transform: rotate(180deg); }
|
||||||
|
.chip-bar { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0 4px; align-items: center; }
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
background: var(--card-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-mid);
|
||||||
|
user-select: none;
|
||||||
|
font-family: var(--font);
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.chip:hover { border-color: var(--gold); color: var(--text-dark); }
|
||||||
|
.chip.active { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── Modal ─────────────────────────────────────────────────── */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15,15,32,.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 12px;
|
||||||
|
z-index: 100;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 680px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: auto 0;
|
||||||
|
}
|
||||||
|
.modal-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; }
|
||||||
|
.modal-header h2 { font-size: 16px; margin: 0; flex: 1; }
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-mid);
|
||||||
|
padding: 2px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; }
|
||||||
|
.modal-footer-actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--card-border); }
|
||||||
|
.btn-ghost-sm {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
padding: 4px 8px; border-radius: 6px; border: none; background: none;
|
||||||
|
font-size: 12px; color: var(--text-mid); cursor: pointer; font-family: var(--font);
|
||||||
|
transition: color .12s, background .12s;
|
||||||
|
}
|
||||||
|
.btn-ghost-sm:hover { color: var(--text-dark); background: var(--card-border); }
|
||||||
|
.btn-ghost-danger { color: var(--danger); }
|
||||||
|
.btn-ghost-danger:hover { color: var(--danger); background: var(--danger-bg); }
|
||||||
|
|
||||||
|
/* ── Timeline / thread ─────────────────────────────────────── */
|
||||||
|
.timeline { margin: 8px 0; }
|
||||||
|
.timeline-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.timeline-item:last-child { border-bottom: none; }
|
||||||
|
.timeline-icon { color: var(--text-mid); flex-shrink: 0; margin-top: 1px; }
|
||||||
|
.timeline-body { flex: 1; min-width: 0; }
|
||||||
|
.timeline-note { white-space: pre-wrap; }
|
||||||
|
.timeline-meta { font-size: 11.5px; color: var(--text-mid); margin-top: 2px; }
|
||||||
|
.timeline-photo-link { color: var(--gold); cursor: pointer; font-size: 12px; font-weight: 500; }
|
||||||
|
.timeline-photo-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ── Tables ────────────────────────────────────────────────── */
|
||||||
|
.table-wrap { overflow-x: auto; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
|
||||||
|
table.data { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
table.data th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 9px 12px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--text-mid);
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
table.data td { padding: 9px 12px; border-bottom: 1px solid var(--card-border); vertical-align: top; }
|
||||||
|
table.data tr:last-child td { border-bottom: none; }
|
||||||
|
table.data tr.clickable { cursor: pointer; }
|
||||||
|
table.data tr.clickable:hover td { background: var(--body-bg); }
|
||||||
|
|
||||||
|
/* ── Stats strip ───────────────────────────────────────────── */
|
||||||
|
.stats-strip { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||||
|
.stat-box {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 10px 16px;
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
.stat-box .stat-value { font-size: 18px; font-weight: 700; }
|
||||||
|
.stat-box .stat-label { font-size: 11px; color: var(--text-mid); text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
|
||||||
|
/* ── Misc ──────────────────────────────────────────────────── */
|
||||||
|
.empty-state { text-align: center; color: var(--text-mid); padding: 40px 16px; font-size: 13.5px; }
|
||||||
|
.error-banner {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin: 18px 0 8px; }
|
||||||
|
.muted { color: var(--text-mid); }
|
||||||
|
.overdue { color: var(--danger); font-weight: 600; }
|
||||||
|
|
||||||
|
/* Sidebar scrollbar */
|
||||||
|
.nav-scroll::-webkit-scrollbar,
|
||||||
|
.sidebar::-webkit-scrollbar,
|
||||||
|
.sidebar-nav::-webkit-scrollbar { width: 4px; }
|
||||||
|
.nav-scroll::-webkit-scrollbar-track,
|
||||||
|
.sidebar::-webkit-scrollbar-track,
|
||||||
|
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.nav-scroll::-webkit-scrollbar-thumb,
|
||||||
|
.sidebar::-webkit-scrollbar-thumb,
|
||||||
|
.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(201,168,76,0.35); border-radius: 2px; }
|
||||||
|
.nav-scroll::-webkit-scrollbar-thumb:hover,
|
||||||
|
.sidebar::-webkit-scrollbar-thumb:hover,
|
||||||
|
.sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); }
|
||||||
|
.nav-scroll, .sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; }
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
Calendar-specific components
|
||||||
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ── View switcher (reuses .chip/.chip.active) ────────────────── */
|
||||||
|
.cal-view-switcher { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
||||||
|
|
||||||
|
/* ── Sidebar calendar toggle list ─────────────────────────────── */
|
||||||
|
.cal-sidebar-calendars { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.cal-toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dark);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.cal-toggle-row:hover { background: var(--body-bg); }
|
||||||
|
.cal-toggle-row input { width: 14px; height: 14px; accent-color: var(--gold); flex-shrink: 0; }
|
||||||
|
.cal-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.cal-toggle-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ── Month grid ────────────────────────────────────────────────── */
|
||||||
|
.cal-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--card-border);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cal-day-header {
|
||||||
|
background: var(--card-bg);
|
||||||
|
padding: 8px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--text-mid);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cal-day {
|
||||||
|
background: var(--card-bg);
|
||||||
|
min-height: 96px;
|
||||||
|
padding: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .12s;
|
||||||
|
}
|
||||||
|
.cal-day:hover { background: var(--body-bg); }
|
||||||
|
.cal-day-num { font-size: 12px; font-weight: 600; color: var(--text-dark); margin-bottom: 2px; }
|
||||||
|
.cal-day.other-month { background: #fafafb; }
|
||||||
|
.cal-day.other-month .cal-day-num { color: var(--text-mid); opacity: .6; }
|
||||||
|
.cal-day.today { background: rgba(201,168,76,.08); }
|
||||||
|
.cal-day.today .cal-day-num {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gold);
|
||||||
|
color: var(--navy);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event-chip {
|
||||||
|
display: block;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cal-event-more { font-size: 11px; color: var(--text-mid); padding: 1px 6px; cursor: pointer; }
|
||||||
|
|
||||||
|
/* ── Week / day grid ───────────────────────────────────────────── */
|
||||||
|
.cal-week-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px repeat(7, 1fr);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
.cal-week-grid.cal-day-view { grid-template-columns: 56px 1fr; }
|
||||||
|
.cal-week-head { display: contents; }
|
||||||
|
.cal-week-head-cell {
|
||||||
|
padding: 8px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-mid);
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
border-left: 1px solid var(--card-border);
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
.cal-week-head-cell.today { color: var(--gold); }
|
||||||
|
.cal-time-gutter { grid-column: 1; }
|
||||||
|
.cal-time-row {
|
||||||
|
height: 48px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--text-mid);
|
||||||
|
text-align: right;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-top: 1px solid var(--card-border);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.cal-day-col {
|
||||||
|
position: relative;
|
||||||
|
border-left: 1px solid var(--card-border);
|
||||||
|
border-top: 1px solid var(--card-border);
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
.cal-day-col-slot {
|
||||||
|
height: 48px;
|
||||||
|
border-top: 1px solid var(--card-border);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.cal-day-col-slot:first-child { border-top: none; }
|
||||||
|
.cal-week-event {
|
||||||
|
position: absolute;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.cal-allday-row {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Agenda / list view ───────────────────────────────────────── */
|
||||||
|
.cal-agenda-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 4px;
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cal-agenda-item:hover { background: var(--body-bg); }
|
||||||
|
.cal-agenda-item:last-child { border-bottom: none; }
|
||||||
|
.cal-agenda-date {
|
||||||
|
width: 64px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-mid);
|
||||||
|
font-weight: 600;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
.cal-agenda-main { flex: 1; min-width: 0; }
|
||||||
|
.cal-agenda-title { font-weight: 600; font-size: 13.5px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.cal-agenda-meta { font-size: 12px; color: var(--text-mid); display: flex; gap: 10px; flex-wrap: wrap; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ── Colour swatch picker ─────────────────────────────────────── */
|
||||||
|
.cal-swatch-row { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0 4px; }
|
||||||
|
.cal-swatch {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: transform .1s, border-color .1s;
|
||||||
|
}
|
||||||
|
.cal-swatch:hover { transform: scale(1.08); }
|
||||||
|
.cal-swatch.active { border-color: var(--navy); }
|
||||||
|
|
||||||
|
/* ── Credential reveal box ────────────────────────────────────── */
|
||||||
|
.cal-credential-box {
|
||||||
|
background: var(--navy);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.cal-credential-box .field-hint { color: var(--gold-light); }
|
||||||
18
frontend/src/main.tsx
Normal file
18
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
|
||||||
|
if (new URLSearchParams(window.location.search).has('install')) {
|
||||||
|
window.addEventListener('beforeinstallprompt', e => {
|
||||||
|
e.preventDefault()
|
||||||
|
;(e as Event & { prompt: () => Promise<void> }).prompt()
|
||||||
|
}, { once: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
113
frontend/src/pages/ActivityLog.tsx
Normal file
113
frontend/src/pages/ActivityLog.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import type { ActivityLogEntry, Calendar } from '../types'
|
||||||
|
import { fetchActivity, fetchCalendars } from '../api'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
export default function ActivityLog() {
|
||||||
|
const [calendars, setCalendars] = useState<Calendar[]>([])
|
||||||
|
const [entries, setEntries] = useState<ActivityLogEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [offset, setOffset] = useState(0)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
|
||||||
|
const [calendarId, setCalendarId] = useState('')
|
||||||
|
const [actorEmail, setActorEmail] = useState('')
|
||||||
|
const [from, setFrom] = useState('')
|
||||||
|
const [to, setTo] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCalendars().then(setCalendars).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const load = useCallback((nextOffset: number, append: boolean) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
fetchActivity({
|
||||||
|
calendar_id: calendarId ? Number(calendarId) : undefined,
|
||||||
|
actor_email: actorEmail || undefined,
|
||||||
|
from: from || undefined,
|
||||||
|
to: to || undefined,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: nextOffset,
|
||||||
|
})
|
||||||
|
.then(rows => {
|
||||||
|
setEntries(prev => append ? [...prev, ...rows] : rows)
|
||||||
|
setHasMore(rows.length === PAGE_SIZE)
|
||||||
|
setOffset(nextOffset)
|
||||||
|
})
|
||||||
|
.catch(err => setError(err.message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [calendarId, actorEmail, from, to])
|
||||||
|
|
||||||
|
useEffect(() => { load(0, false) }, [load])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page" style={{ maxWidth: 1100 }}>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Activity Log</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
<div className="filter-row" style={{ flexWrap: 'wrap', marginBottom: 14 }}>
|
||||||
|
<div className="field" style={{ marginBottom: 0, minWidth: 160 }}>
|
||||||
|
<label>Calendar</label>
|
||||||
|
<select value={calendarId} onChange={e => setCalendarId(e.target.value)}>
|
||||||
|
<option value="">All calendars</option>
|
||||||
|
{calendars.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ marginBottom: 0, minWidth: 160 }}>
|
||||||
|
<label>Actor email</label>
|
||||||
|
<input type="text" value={actorEmail} onChange={e => setActorEmail(e.target.value)} placeholder="name@hotel..." />
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
|
<label>From</label>
|
||||||
|
<input type="date" value={from} onChange={e => setFrom(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
|
<label>To</label>
|
||||||
|
<input type="date" value={to} onChange={e => setTo(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Actor</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Entity</th>
|
||||||
|
<th>Summary</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map(e => (
|
||||||
|
<tr key={e.id}>
|
||||||
|
<td style={{ whiteSpace: 'nowrap' }}>{new Date(e.created_at).toLocaleString()}</td>
|
||||||
|
<td>{e.actor_name || e.actor_email}</td>
|
||||||
|
<td>{e.action}</td>
|
||||||
|
<td>{e.entity_type} #{e.entity_id}</td>
|
||||||
|
<td>{e.summary}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{entries.length === 0 && !loading && (
|
||||||
|
<tr><td colSpan={5} className="empty-state">No activity found.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="empty-state">Loading…</div>}
|
||||||
|
|
||||||
|
{hasMore && !loading && entries.length > 0 && (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 12 }}>
|
||||||
|
<button className="btn" onClick={() => load(offset + PAGE_SIZE, true)}>Load more</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
174
frontend/src/pages/CalDavSetup.tsx
Normal file
174
frontend/src/pages/CalDavSetup.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Copy, Plus, Smartphone, Trash2, TriangleAlert } from 'lucide-react'
|
||||||
|
import type { CaldavCredential, CaldavCredentialCreated } from '../types'
|
||||||
|
import { fetchCaldavCredentials, createCaldavCredential, deleteCaldavCredential } from '../api'
|
||||||
|
|
||||||
|
export default function CalDavSetup() {
|
||||||
|
const [credentials, setCredentials] = useState<CaldavCredential[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [label, setLabel] = useState('')
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [revealed, setRevealed] = useState<CaldavCredentialCreated | null>(null)
|
||||||
|
|
||||||
|
const caldavUrl = `${window.location.origin}/calendar/caldav/`
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
fetchCaldavCredentials().then(setCredentials).catch(err => setError(err.message)).finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { reload() }, [reload])
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
setCreating(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const created = await createCaldavCredential(label.trim() || undefined)
|
||||||
|
setRevealed(created)
|
||||||
|
setLabel('')
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create credential')
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(id: number) {
|
||||||
|
if (!confirm('Revoke this device? It will stop syncing immediately.')) return
|
||||||
|
try {
|
||||||
|
await deleteCaldavCredential(id)
|
||||||
|
if (revealed?.id === id) setRevealed(null)
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to revoke credential')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copy(text: string) {
|
||||||
|
navigator.clipboard?.writeText(text).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page" style={{ maxWidth: 760 }}>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Phone Sync</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<p style={{ marginTop: 0 }}>
|
||||||
|
Subscribe to this calendar from your phone or computer's own calendar app (Apple Calendar,
|
||||||
|
Google Calendar, Outlook, …) using <strong>CalDAV</strong>. Once set up, events created here
|
||||||
|
show up on your device automatically, and new device-created events sync back — no separate app
|
||||||
|
needed.
|
||||||
|
</p>
|
||||||
|
<p style={{ marginBottom: 0 }}>
|
||||||
|
Each device needs its own generated username and password below — never share your normal
|
||||||
|
hotel login for this.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
<div className="section-title">Your CalDAV devices</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="empty-state">Loading…</div>
|
||||||
|
) : credentials.length === 0 ? (
|
||||||
|
<div className="empty-state">No devices set up yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Last used</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{credentials.map(c => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td>{c.label || '—'}</td>
|
||||||
|
<td>{c.username}</td>
|
||||||
|
<td>{new Date(c.created_at).toLocaleDateString()}</td>
|
||||||
|
<td>{c.last_used_at ? new Date(c.last_used_at).toLocaleString() : 'Never'}</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>
|
||||||
|
<button className="btn-ghost-sm btn-ghost-danger" onClick={() => handleRevoke(c.id)}>
|
||||||
|
<Trash2 size={13} strokeWidth={1.75} /> Revoke
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="field-row" style={{ alignItems: 'flex-end', marginTop: 12 }}>
|
||||||
|
<div className="field" style={{ flex: 1 }}>
|
||||||
|
<label>Device label (optional)</label>
|
||||||
|
<input type="text" value={label} onChange={e => setLabel(e.target.value)} placeholder="e.g. Sarah's iPhone" />
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={handleCreate} disabled={creating} style={{ marginBottom: 12 }}>
|
||||||
|
<Plus size={14} strokeWidth={1.75} />
|
||||||
|
{creating ? 'Generating…' : 'Generate new'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{revealed && (
|
||||||
|
<div className="card" style={{ borderColor: 'var(--gold)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--danger)', fontWeight: 600, marginBottom: 8 }}>
|
||||||
|
<TriangleAlert size={16} strokeWidth={1.75} />
|
||||||
|
Save this now — the password won't be shown again.
|
||||||
|
</div>
|
||||||
|
<div className="cal-credential-box">
|
||||||
|
<div>Username: {revealed.username} <Copy size={13} strokeWidth={1.75} style={{ cursor: 'pointer', verticalAlign: 'middle' }} onClick={() => copy(revealed.username)} /></div>
|
||||||
|
<div>Password: {revealed.password} <Copy size={13} strokeWidth={1.75} style={{ cursor: 'pointer', verticalAlign: 'middle' }} onClick={() => copy(revealed.password)} /></div>
|
||||||
|
<div>Server URL: {caldavUrl} <Copy size={13} strokeWidth={1.75} style={{ cursor: 'pointer', verticalAlign: 'middle' }} onClick={() => copy(caldavUrl)} /></div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-sm" onClick={() => setRevealed(null)}>I've saved it, hide this</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="section-title">Set-up instructions</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Smartphone size={15} strokeWidth={1.75} /> Apple Calendar (iPhone / Mac)
|
||||||
|
</div>
|
||||||
|
<ol style={{ marginTop: 0, paddingLeft: 20 }}>
|
||||||
|
<li>Settings → Calendar → Accounts → Add Account → Other → Add CalDAV Account.</li>
|
||||||
|
<li>Server: <code>{caldavUrl}</code></li>
|
||||||
|
<li>User Name / Password: the credentials generated above.</li>
|
||||||
|
<li>Tap Next, then Save.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Smartphone size={15} strokeWidth={1.75} /> Google Calendar
|
||||||
|
</div>
|
||||||
|
<ol style={{ marginTop: 0, paddingLeft: 20 }}>
|
||||||
|
<li>Google Calendar doesn't support direct CalDAV subscriptions on the free tier — easiest is to
|
||||||
|
use a CalDAV-sync app such as "CalDAV-Sync" (Android) with the server URL and credentials above.</li>
|
||||||
|
<li>Alternatively, on desktop, add it as a "secondary" calendar in a CalDAV-aware client and it
|
||||||
|
will appear alongside Google Calendar.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Smartphone size={15} strokeWidth={1.75} /> Outlook
|
||||||
|
</div>
|
||||||
|
<ol style={{ marginTop: 0, paddingLeft: 20 }}>
|
||||||
|
<li>Outlook (desktop): File → Account Settings → Internet Calendars → New, then paste <code>{caldavUrl}</code>.</li>
|
||||||
|
<li>When prompted, enter the username and password generated above.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
200
frontend/src/pages/CalendarSettings.tsx
Normal file
200
frontend/src/pages/CalendarSettings.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Lock, Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||||
|
import type { Calendar } from '../types'
|
||||||
|
import { fetchCalendars, createCalendar, updateCalendar, deleteCalendar } from '../api'
|
||||||
|
|
||||||
|
// Curated swatch — similarly saturated hues that read well against the navy/gold theme.
|
||||||
|
const SWATCHES = [
|
||||||
|
'#c9a84c', '#2563eb', '#16a34a', '#dc2626', '#7c3aed',
|
||||||
|
'#0d9488', '#d97706', '#db2777', '#4f46e5', '#64748b',
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function CalendarSettings() {
|
||||||
|
const [calendars, setCalendars] = useState<Calendar[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
|
const [editName, setEditName] = useState('')
|
||||||
|
const [editColor, setEditColor] = useState(SWATCHES[0])
|
||||||
|
|
||||||
|
const [newOpen, setNewOpen] = useState(false)
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [newColor, setNewColor] = useState(SWATCHES[0])
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
fetchCalendars().then(setCalendars).catch(err => setError(err.message)).finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { reload() }, [reload])
|
||||||
|
|
||||||
|
function startEdit(cal: Calendar) {
|
||||||
|
setEditingId(cal.id)
|
||||||
|
setEditName(cal.name)
|
||||||
|
setEditColor(cal.color)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(id: number) {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await updateCalendar(id, { name: editName.trim(), color: editColor })
|
||||||
|
setEditingId(null)
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Update failed')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(cal: Calendar) {
|
||||||
|
if (!confirm(`Delete calendar "${cal.name}"? Events on it will also be removed.`)) return
|
||||||
|
try {
|
||||||
|
await deleteCalendar(cal.id)
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Delete failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!newName.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await createCalendar({ name: newName.trim(), color: newColor })
|
||||||
|
setNewName('')
|
||||||
|
setNewColor(SWATCHES[0])
|
||||||
|
setNewOpen(false)
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Create failed')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Calendars</h1>
|
||||||
|
<button className="btn btn-primary" onClick={() => setNewOpen(o => !o)}>
|
||||||
|
{newOpen ? <X size={14} strokeWidth={1.75} /> : <Plus size={14} strokeWidth={1.75} />}
|
||||||
|
{newOpen ? 'Cancel' : 'New calendar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
{newOpen && (
|
||||||
|
<div className="card">
|
||||||
|
<form onSubmit={handleCreate}>
|
||||||
|
<div className="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" value={newName} onChange={e => setNewName(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Colour</label>
|
||||||
|
<div className="cal-swatch-row">
|
||||||
|
{SWATCHES.map(sw => (
|
||||||
|
<span
|
||||||
|
key={sw}
|
||||||
|
className={`cal-swatch ${newColor === sw ? 'active' : ''}`}
|
||||||
|
style={{ background: sw }}
|
||||||
|
onClick={() => setNewColor(sw)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Saving…' : 'Create'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="empty-state">Loading…</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{calendars.map(cal => (
|
||||||
|
<tr key={cal.id}>
|
||||||
|
{editingId === cal.id ? (
|
||||||
|
<>
|
||||||
|
<td style={{ width: 40 }}>
|
||||||
|
<div className="cal-swatch-row" style={{ margin: 0 }}>
|
||||||
|
{SWATCHES.map(sw => (
|
||||||
|
<span
|
||||||
|
key={sw}
|
||||||
|
className={`cal-swatch ${editColor === sw ? 'active' : ''}`}
|
||||||
|
style={{ background: sw, width: 18, height: 18 }}
|
||||||
|
onClick={() => setEditColor(sw)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" value={editName} onChange={e => setEditName(e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
|
<button className="btn btn-sm btn-primary" onClick={() => saveEdit(cal.id)} disabled={saving}>Save</button>{' '}
|
||||||
|
<button className="btn btn-sm" onClick={() => setEditingId(null)}>Cancel</button>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td style={{ width: 40 }}>
|
||||||
|
<span className="cal-dot" style={{ background: cal.color, width: 14, height: 14 }} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{cal.name}
|
||||||
|
{cal.is_system && (
|
||||||
|
<span className="badge badge-outline" style={{ marginLeft: 8 }}>
|
||||||
|
<Lock size={11} strokeWidth={1.75} /> system
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
|
<button
|
||||||
|
className="btn-ghost-sm"
|
||||||
|
onClick={() => startEdit(cal)}
|
||||||
|
disabled={cal.is_system}
|
||||||
|
>
|
||||||
|
<Pencil size={13} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-ghost-sm btn-ghost-danger"
|
||||||
|
onClick={() => handleDelete(cal)}
|
||||||
|
disabled={cal.is_system}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{calendars.length === 0 && (
|
||||||
|
<tr><td colSpan={3} className="empty-state">No calendars yet.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
165
frontend/src/pages/CalendarView.tsx
Normal file
165
frontend/src/pages/CalendarView.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'
|
||||||
|
import type { Calendar, EventSummary } from '../types'
|
||||||
|
import { can } from '../types'
|
||||||
|
import { useAuth } from '../components/AuthGate'
|
||||||
|
import { fetchCalendars, fetchEvents } from '../api'
|
||||||
|
import CalendarToggleList from '../components/CalendarToggleList'
|
||||||
|
import ViewSwitcher, { type CalendarViewKey } from '../components/ViewSwitcher'
|
||||||
|
import EventForm from '../components/EventForm'
|
||||||
|
import MonthGrid from '../components/views/MonthGrid'
|
||||||
|
import WeekGrid from '../components/views/WeekGrid'
|
||||||
|
import DayGrid from '../components/views/DayGrid'
|
||||||
|
import AgendaList from '../components/views/AgendaList'
|
||||||
|
import {
|
||||||
|
addDays, addMonths, monthGridDays, startOfWeek, toISODate,
|
||||||
|
formatMonthLabel, formatWeekLabel, formatDateLabel,
|
||||||
|
} from '../dateUtils'
|
||||||
|
|
||||||
|
function rangeFor(view: CalendarViewKey, date: Date): { from: Date; to: Date } {
|
||||||
|
if (view === 'month') {
|
||||||
|
const days = monthGridDays(date)
|
||||||
|
return { from: days[0], to: days[days.length - 1] }
|
||||||
|
}
|
||||||
|
if (view === 'week') {
|
||||||
|
const start = startOfWeek(date)
|
||||||
|
return { from: start, to: addDays(start, 6) }
|
||||||
|
}
|
||||||
|
if (view === 'day') {
|
||||||
|
return { from: date, to: date }
|
||||||
|
}
|
||||||
|
// list/agenda — rolling 30-day window from the current date
|
||||||
|
return { from: date, to: addDays(date, 30) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFor(view: CalendarViewKey, date: Date): string {
|
||||||
|
if (view === 'month') return formatMonthLabel(date)
|
||||||
|
if (view === 'week') return formatWeekLabel(date)
|
||||||
|
if (view === 'day') return formatDateLabel(date)
|
||||||
|
return `Next 30 days from ${formatDateLabel(date)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftDate(view: CalendarViewKey, date: Date, dir: 1 | -1): Date {
|
||||||
|
if (view === 'month') return addMonths(date, dir)
|
||||||
|
if (view === 'week') return addDays(date, 7 * dir)
|
||||||
|
return addDays(date, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarView() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [view, setView] = useState<CalendarViewKey>('month')
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date())
|
||||||
|
const [calendars, setCalendars] = useState<Calendar[]>([])
|
||||||
|
const [visibleIds, setVisibleIds] = useState<Set<number>>(new Set())
|
||||||
|
const [events, setEvents] = useState<EventSummary[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [formState, setFormState] = useState<{ open: boolean; eventId?: number; initialDate?: Date }>({ open: false })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCalendars().then(cals => {
|
||||||
|
setCalendars(cals)
|
||||||
|
setVisibleIds(new Set(cals.map(c => c.id)))
|
||||||
|
}).catch(err => setError(err.message))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
const { from, to } = rangeFor(view, currentDate)
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
fetchEvents({ from: toISODate(from), to: toISODate(to) })
|
||||||
|
.then(setEvents)
|
||||||
|
.catch(err => setError(err.message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [view, currentDate])
|
||||||
|
|
||||||
|
useEffect(() => { reload() }, [reload])
|
||||||
|
|
||||||
|
function toggleCalendar(id: number) {
|
||||||
|
setVisibleIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleEvents = events.filter(ev => visibleIds.has(ev.calendar_id))
|
||||||
|
|
||||||
|
function openCreate(date: Date) {
|
||||||
|
if (!can(user, 'create')) return
|
||||||
|
setFormState({ open: true, initialDate: date })
|
||||||
|
}
|
||||||
|
function openEdit(id: number) {
|
||||||
|
setFormState({ open: true, eventId: id })
|
||||||
|
}
|
||||||
|
function closeForm() {
|
||||||
|
setFormState({ open: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewProps = {
|
||||||
|
events: visibleEvents,
|
||||||
|
date: currentDate,
|
||||||
|
onSelectDate: openCreate,
|
||||||
|
onSelectEvent: openEdit,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page" style={{ maxWidth: 1300 }}>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Calendar</h1>
|
||||||
|
{can(user, 'create') && (
|
||||||
|
<button className="btn btn-primary" onClick={() => openCreate(currentDate)}>
|
||||||
|
<Plus size={14} strokeWidth={1.75} />
|
||||||
|
New event
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||||
|
<div className="card" style={{ width: 220, flexShrink: 0 }}>
|
||||||
|
<div className="section-title" style={{ marginTop: 0 }}>Calendars</div>
|
||||||
|
<CalendarToggleList calendars={calendars} visibleIds={visibleIds} onToggle={toggleCalendar} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="filter-row" style={{ marginBottom: 12, alignItems: 'center' }}>
|
||||||
|
<button className="btn btn-sm" onClick={() => setCurrentDate(shiftDate(view, currentDate, -1))}>
|
||||||
|
<ChevronLeft size={14} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm" onClick={() => setCurrentDate(new Date())}>Today</button>
|
||||||
|
<button className="btn btn-sm" onClick={() => setCurrentDate(shiftDate(view, currentDate, 1))}>
|
||||||
|
<ChevronRight size={14} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14, flex: 1 }}>{labelFor(view, currentDate)}</div>
|
||||||
|
<ViewSwitcher view={view} onChange={setView} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="empty-state">Loading…</div>
|
||||||
|
) : view === 'month' ? (
|
||||||
|
<MonthGrid {...viewProps} />
|
||||||
|
) : view === 'week' ? (
|
||||||
|
<WeekGrid {...viewProps} />
|
||||||
|
) : view === 'day' ? (
|
||||||
|
<DayGrid {...viewProps} />
|
||||||
|
) : (
|
||||||
|
<AgendaList {...viewProps} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formState.open && (
|
||||||
|
<EventForm
|
||||||
|
eventId={formState.eventId}
|
||||||
|
initialDate={formState.initialDate}
|
||||||
|
onClose={closeForm}
|
||||||
|
onSaved={reload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
89
frontend/src/pages/Dashboard.tsx
Normal file
89
frontend/src/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { CalendarClock, Users2 } from 'lucide-react'
|
||||||
|
import type { Department, EventSummary } from '../types'
|
||||||
|
import { fetchMyUpcoming, fetchMyDepartments } from '../api'
|
||||||
|
import EventForm from '../components/EventForm'
|
||||||
|
import { formatDayHeader, formatTime } from '../dateUtils'
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [upcoming, setUpcoming] = useState<EventSummary[]>([])
|
||||||
|
const [departments, setDepartments] = useState<Department[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [openEventId, setOpenEventId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
Promise.all([fetchMyUpcoming(7), fetchMyDepartments()])
|
||||||
|
.then(([ev, depts]) => { setUpcoming(ev); setDepartments(depts) })
|
||||||
|
.catch(err => setError(err.message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { reload() }, [reload])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
<div className="stats-strip">
|
||||||
|
<div className="stat-box">
|
||||||
|
<div className="stat-value">{upcoming.length}</div>
|
||||||
|
<div className="stat-label">Upcoming (7 days)</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-box">
|
||||||
|
<div className="stat-value">{departments.length}</div>
|
||||||
|
<div className="stat-label">My departments</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section-title" style={{ marginTop: 0 }}>Your departments</div>
|
||||||
|
{departments.length === 0 ? (
|
||||||
|
<div className="empty-state">You're not assigned to any departments.</div>
|
||||||
|
) : (
|
||||||
|
<div className="chip-bar" style={{ marginBottom: 8 }}>
|
||||||
|
{departments.map(d => (
|
||||||
|
<span key={d.id} className="badge badge-outline">
|
||||||
|
<Users2 size={12} strokeWidth={1.75} />
|
||||||
|
{d.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="section-title">Upcoming events</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="empty-state">Loading…</div>
|
||||||
|
) : upcoming.length === 0 ? (
|
||||||
|
<div className="empty-state">Nothing on your calendar in the next 7 days.</div>
|
||||||
|
) : (
|
||||||
|
upcoming.map(ev => (
|
||||||
|
<div key={ev.id} className="card task-card" onClick={() => setOpenEventId(ev.id)}>
|
||||||
|
<CalendarClock size={16} strokeWidth={1.75} color={ev.calendar_color} style={{ marginTop: 2 }} />
|
||||||
|
<div className="task-card-main">
|
||||||
|
<div className="task-card-title">{ev.title}</div>
|
||||||
|
<div className="task-card-meta">
|
||||||
|
<span>{formatDayHeader(new Date(ev.start_at))}{!ev.all_day && ` · ${formatTime(ev.start_at)}`}</span>
|
||||||
|
{ev.location && <span>{ev.location}</span>}
|
||||||
|
<span className="badge-outline badge">{ev.calendar_name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{openEventId !== null && (
|
||||||
|
<EventForm
|
||||||
|
eventId={openEventId}
|
||||||
|
onClose={() => setOpenEventId(null)}
|
||||||
|
onSaved={reload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
frontend/src/sw.js
Normal file
11
frontend/src/sw.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'
|
||||||
|
import { NavigationRoute, registerRoute } from 'workbox-routing'
|
||||||
|
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST)
|
||||||
|
|
||||||
|
// SPA fallback: navigate requests that don't match a cached asset serve index.html
|
||||||
|
registerRoute(
|
||||||
|
new NavigationRoute(createHandlerBoundToURL('/calendar/index.html'), {
|
||||||
|
denylist: [/\/api\//],
|
||||||
|
})
|
||||||
|
)
|
||||||
109
frontend/src/types.ts
Normal file
109
frontend/src/types.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
export interface Calendar {
|
||||||
|
id: number
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
is_system: boolean
|
||||||
|
created_by: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventSummary {
|
||||||
|
id: number
|
||||||
|
calendar_id: number
|
||||||
|
calendar_name: string
|
||||||
|
calendar_color: string
|
||||||
|
uid: string
|
||||||
|
title: string
|
||||||
|
location: string | null
|
||||||
|
start_at: string
|
||||||
|
end_at: string
|
||||||
|
all_day: boolean
|
||||||
|
department_names: string[]
|
||||||
|
assignee_names: string[]
|
||||||
|
attachment_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventDepartment {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventAssignee {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventAttachment {
|
||||||
|
id: number
|
||||||
|
filename: string
|
||||||
|
mime_type: string
|
||||||
|
size_bytes: number
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventDetail extends EventSummary {
|
||||||
|
description: string | null
|
||||||
|
departments: EventDepartment[]
|
||||||
|
assignees: EventAssignee[]
|
||||||
|
attachments: EventAttachment[]
|
||||||
|
calendar: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
is_system: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityLogEntry {
|
||||||
|
id: number
|
||||||
|
actor_email: string
|
||||||
|
actor_name: string
|
||||||
|
action: string
|
||||||
|
entity_type: string
|
||||||
|
entity_id: number
|
||||||
|
calendar_id: number | null
|
||||||
|
summary: string
|
||||||
|
details: string | null
|
||||||
|
source: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Department {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaldavCredential {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
label: string | null
|
||||||
|
created_at: string
|
||||||
|
last_used_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returned once, immediately after creation — password is never retrievable again.
|
||||||
|
export interface CaldavCredentialCreated {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
label: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
is_admin: boolean
|
||||||
|
caps: string[] // bare slugs — verify?app=calendar strips the prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
export function can(user: User, cap: string): boolean {
|
||||||
|
return user.is_admin || user.caps.includes(cap)
|
||||||
|
}
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
32
frontend/vite.config.ts
Normal file
32
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: '/calendar/',
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'src',
|
||||||
|
filename: 'sw.js',
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
manifest: {
|
||||||
|
name: 'Calendar',
|
||||||
|
short_name: 'Calendar',
|
||||||
|
start_url: '/calendar/',
|
||||||
|
scope: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
theme_color: '#c9a84c',
|
||||||
|
background_color: '#c9a84c',
|
||||||
|
icons: [
|
||||||
|
{ src: '/calendar/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: '/calendar/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
injectManifest: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
63
seed-app.js
Normal file
63
seed-app.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Run from calendar/ dir: DATABASE_URL=... node seed-app.js
|
||||||
|
//
|
||||||
|
// NOTE: this follows the *actual* auth schema (role_capabilities has a
|
||||||
|
// capability_id column — see auth/src/db.js) rather than maintenance/ and
|
||||||
|
// room-planner/'s seed-app.js, which both insert into a non-existent
|
||||||
|
// `cap_id` column. Copied the 3-step pattern, fixed the column name.
|
||||||
|
import pg from 'pg'
|
||||||
|
|
||||||
|
const { Pool } = pg
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
|
||||||
|
VALUES ('calendar', 'Calendar', 'Shared events calendar — departments, staff, bank holidays, phone sync', '/calendar', 'CalendarDays', '#c9a84c', 'Operations', '10.10.10.126', 3080)
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
base_path = EXCLUDED.base_path,
|
||||||
|
icon = EXCLUDED.icon,
|
||||||
|
theme_color = EXCLUDED.theme_color,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
internal_host = EXCLUDED.internal_host,
|
||||||
|
internal_port = EXCLUDED.internal_port
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Seed capabilities
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO app_capabilities (app_id, slug, name, description, sort_order)
|
||||||
|
SELECT a.id, c.slug, c.name, c.description, c.sort_order
|
||||||
|
FROM apps a, (VALUES
|
||||||
|
('view', 'View Calendar', 'View events, calendars and bank holidays', 1),
|
||||||
|
('create', 'Create Events', 'Add new events to non-system calendars', 2),
|
||||||
|
('edit', 'Edit Events', 'Edit, delete and attach files to events; manage own CalDAV credentials', 3),
|
||||||
|
('manage_calendars', 'Manage Calendars', 'Create, rename, recolour and delete calendars', 4),
|
||||||
|
('admin', 'View Activity Log & Admin', 'View the full activity/audit log', 5)
|
||||||
|
) AS c(slug, name, description, sort_order)
|
||||||
|
WHERE a.slug = 'calendar'
|
||||||
|
ON CONFLICT (app_id, slug) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
sort_order = EXCLUDED.sort_order
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Grant view + create to Staff role if they have no calendar caps yet
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO role_capabilities (role_id, capability_id)
|
||||||
|
SELECT r.id, ac.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN app_capabilities ac ON ac.app_id = (SELECT id FROM apps WHERE slug = 'calendar')
|
||||||
|
JOIN apps a ON a.id = ac.app_id
|
||||||
|
WHERE r.slug = 'staff'
|
||||||
|
AND ac.slug IN ('view', 'create')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM role_capabilities rc
|
||||||
|
JOIN app_capabilities ac2 ON ac2.id = rc.capability_id
|
||||||
|
WHERE rc.role_id = r.id AND ac2.app_id = a.id
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`)
|
||||||
|
|
||||||
|
console.log('calendar app seeded.')
|
||||||
|
await pool.end()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue