diff --git a/.env.example b/.env.example index dffe0dd..9fd611d 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,7 @@ PROJECT_NAME=jama # Image version to run (set by build.sh, or use 'latest') -JAMA_VERSION=0.9.45 +JAMA_VERSION=0.9.46 # App port — the host port Docker maps to the container PORT=3000 diff --git a/backend/package.json b/backend/package.json index 48a12a9..9f97aff 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.9.45", + "version": "0.9.46", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { @@ -19,7 +19,8 @@ "sharp": "^0.33.2", "socket.io": "^4.6.1", "web-push": "^3.6.7", - "better-sqlite3-multiple-ciphers": "^12.6.2" + "better-sqlite3-multiple-ciphers": "^12.6.2", + "csv-parse": "^5.5.6" }, "devDependencies": { "nodemon": "^3.0.2" diff --git a/backend/src/index.js b/backend/src/index.js index 4a4f40b..1ee2015 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -42,6 +42,7 @@ app.use('/api/users', require('./routes/users')); app.use('/api/groups', require('./routes/groups')(io)); app.use('/api/messages', require('./routes/messages')(io)); app.use('/api/usergroups', require('./routes/usergroups')(io)); +app.use('/api/schedule', require('./routes/schedule')); app.use('/api/settings', require('./routes/settings')); app.use('/api/about', require('./routes/about')); app.use('/api/help', require('./routes/help')); diff --git a/backend/src/models/db.js b/backend/src/models/db.js index d837965..8d10775 100644 --- a/backend/src/models/db.js +++ b/backend/src/models/db.js @@ -381,6 +381,57 @@ function initDb() { console.log('[DB] Migration: user_groups tables ready'); } catch (e) { console.error('[DB] user_groups migration error:', e.message); } + // ── Schedule Manager ──────────────────────────────────────────────────────── + try { + db.exec(` + CREATE TABLE IF NOT EXISTS event_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + colour TEXT NOT NULL DEFAULT '#6366f1', + default_user_group_id INTEGER, + default_duration_hrs REAL NOT NULL DEFAULT 1.0, + is_default INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (default_user_group_id) REFERENCES user_groups(id) ON DELETE SET NULL + ); + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + event_type_id INTEGER, + start_at TEXT NOT NULL, + end_at TEXT NOT NULL, + all_day INTEGER NOT NULL DEFAULT 0, + location TEXT, + description TEXT, + is_public INTEGER NOT NULL DEFAULT 1, + track_availability INTEGER NOT NULL DEFAULT 0, + created_by INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (event_type_id) REFERENCES event_types(id) ON DELETE SET NULL, + FOREIGN KEY (created_by) REFERENCES users(id) + ); + CREATE TABLE IF NOT EXISTS event_user_groups ( + event_id INTEGER NOT NULL, + user_group_id INTEGER NOT NULL, + PRIMARY KEY (event_id, user_group_id), + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE, + FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS event_availability ( + event_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + response TEXT NOT NULL CHECK(response IN ('going','maybe','not_going')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (event_id, user_id), + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + `); + db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_default) VALUES ('Default', '#9ca3af', 1)").run(); + console.log('[DB] Schedule Manager tables ready'); + } catch (e) { console.error('[DB] Schedule Manager migration error:', e.message); } + console.log('[DB] Schema initialized'); return db; } diff --git a/backend/src/routes/schedule.js b/backend/src/routes/schedule.js new file mode 100644 index 0000000..13cf34f --- /dev/null +++ b/backend/src/routes/schedule.js @@ -0,0 +1,327 @@ +const express = require('express'); +const router = express.Router(); +const { getDb } = require('../models/db'); +const { authMiddleware, teamManagerMiddleware } = require('../middleware/auth'); +const multer = require('multer'); +const { parse: csvParse } = require('csv-parse/sync'); +const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } }); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function canViewEvent(db, event, userId, isToolManager) { + if (isToolManager) return true; + if (event.is_public) return true; + // Private: user must be in an assigned user group + const assigned = db.prepare(` + SELECT 1 FROM event_user_groups eug + JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id + WHERE eug.event_id = ? AND ugm.user_id = ? + `).get(event.id, userId); + return !!assigned; +} + +function isToolManagerFn(db, user) { + if (user.role === 'admin') return true; + const tmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_tool_managers'").get(); + const gmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_group_managers'").get(); + const groupIds = [...new Set([ + ...JSON.parse(tmSetting?.value || '[]'), + ...JSON.parse(gmSetting?.value || '[]'), + ])]; + if (!groupIds.length) return false; + return !!db.prepare(`SELECT 1 FROM user_group_members WHERE user_id = ? AND user_group_id IN (${groupIds.map(()=>'?').join(',')})`).get(user.id, ...groupIds); +} + +function enrichEvent(db, event) { + event.event_type = event.event_type_id + ? db.prepare('SELECT * FROM event_types WHERE id = ?').get(event.event_type_id) + : null; + event.user_groups = db.prepare(` + SELECT ug.id, ug.name FROM event_user_groups eug + JOIN user_groups ug ON ug.id = eug.user_group_id + WHERE eug.event_id = ? + `).all(event.id); + return event; +} + +// ── Event Types ─────────────────────────────────────────────────────────────── + +router.get('/event-types', authMiddleware, (req, res) => { + const db = getDb(); + res.json({ eventTypes: db.prepare('SELECT * FROM event_types ORDER BY is_default DESC, name ASC').all() }); +}); + +router.post('/event-types', authMiddleware, teamManagerMiddleware, (req, res) => { + const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Name required' }); + const db = getDb(); + if (db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?)').get(name.trim())) { + return res.status(400).json({ error: 'Event type with that name already exists' }); + } + const r = db.prepare(`INSERT INTO event_types (name, colour, default_user_group_id, default_duration_hrs) + VALUES (?, ?, ?, ?)`).run(name.trim(), colour || '#6366f1', defaultUserGroupId || null, defaultDurationHrs || 1.0); + res.json({ eventType: db.prepare('SELECT * FROM event_types WHERE id = ?').get(r.lastInsertRowid) }); +}); + +router.patch('/event-types/:id', authMiddleware, teamManagerMiddleware, (req, res) => { + const db = getDb(); + const et = db.prepare('SELECT * FROM event_types WHERE id = ?').get(req.params.id); + if (!et) return res.status(404).json({ error: 'Not found' }); + if (et.is_default) return res.status(403).json({ error: 'Cannot edit the Default event type' }); + const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body; + if (name && name.trim() !== et.name) { + if (db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), et.id)) + return res.status(400).json({ error: 'Name already in use' }); + } + db.prepare(`UPDATE event_types SET + name = COALESCE(?, name), + colour = COALESCE(?, colour), + default_user_group_id = ?, + default_duration_hrs = COALESCE(?, default_duration_hrs) + WHERE id = ?`).run(name?.trim() || null, colour || null, defaultUserGroupId ?? et.default_user_group_id, defaultDurationHrs || null, et.id); + res.json({ eventType: db.prepare('SELECT * FROM event_types WHERE id = ?').get(et.id) }); +}); + +router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, (req, res) => { + const db = getDb(); + const et = db.prepare('SELECT * FROM event_types WHERE id = ?').get(req.params.id); + if (!et) return res.status(404).json({ error: 'Not found' }); + if (et.is_default) return res.status(403).json({ error: 'Cannot delete the Default event type' }); + // Null out event_type_id on events using this type + db.prepare('UPDATE events SET event_type_id = NULL WHERE event_type_id = ?').run(et.id); + db.prepare('DELETE FROM event_types WHERE id = ?').run(et.id); + res.json({ success: true }); +}); + +// ── Events ──────────────────────────────────────────────────────────────────── + +// List events (with optional date range filter) +router.get('/', authMiddleware, (req, res) => { + const db = getDb(); + const itm = isToolManagerFn(db, req.user); + const { from, to } = req.query; + let q = 'SELECT * FROM events WHERE 1=1'; + const params = []; + if (from) { q += ' AND end_at >= ?'; params.push(from); } + if (to) { q += ' AND start_at <= ?'; params.push(to); } + q += ' ORDER BY start_at ASC'; + const events = db.prepare(q).all(...params) + .filter(e => canViewEvent(db, e, req.user.id, itm)) + .map(e => enrichEvent(db, e)); + res.json({ events }); +}); + +// Get single event +router.get('/:id', authMiddleware, (req, res) => { + const db = getDb(); + const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id); + if (!event) return res.status(404).json({ error: 'Not found' }); + const itm = isToolManagerFn(db, req.user); + if (!canViewEvent(db, event, req.user.id, itm)) return res.status(403).json({ error: 'Access denied' }); + enrichEvent(db, event); + // Availability (only for assigned group members / tool managers) + if (event.track_availability && itm) { + const responses = db.prepare(` + SELECT ea.response, ea.updated_at, u.id as user_id, u.name, u.display_name, u.avatar + FROM event_availability ea JOIN users u ON u.id = ea.user_id + WHERE ea.event_id = ? + `).all(req.params.id); + event.availability = responses; + // Count no-response: users in assigned groups who haven't responded + const assignedUserIds = db.prepare(` + SELECT DISTINCT ugm.user_id FROM event_user_groups eug + JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id + WHERE eug.event_id = ? + `).all(req.params.id).map(r => r.user_id); + const respondedIds = new Set(responses.map(r => r.user_id)); + event.no_response_count = assignedUserIds.filter(id => !respondedIds.has(id)).length; + } + // Current user's own response + const mine = db.prepare('SELECT response FROM event_availability WHERE event_id = ? AND user_id = ?').get(req.params.id, req.user.id); + event.my_response = mine?.response || null; + res.json({ event }); +}); + +// Create event +router.post('/', authMiddleware, teamManagerMiddleware, (req, res) => { + const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds = [] } = req.body; + if (!title?.trim()) return res.status(400).json({ error: 'Title required' }); + if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' }); + const db = getDb(); + const r = db.prepare(`INSERT INTO events (title, event_type_id, start_at, end_at, all_day, location, description, is_public, track_availability, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + title.trim(), eventTypeId || null, startAt, endAt, + allDay ? 1 : 0, location || null, description || null, + isPublic !== false ? 1 : 0, trackAvailability ? 1 : 0, req.user.id + ); + const eventId = r.lastInsertRowid; + for (const ugId of (Array.isArray(userGroupIds) ? userGroupIds : [])) + db.prepare('INSERT OR IGNORE INTO event_user_groups (event_id, user_group_id) VALUES (?, ?)').run(eventId, ugId); + const event = db.prepare('SELECT * FROM events WHERE id = ?').get(eventId); + res.json({ event: enrichEvent(db, event) }); +}); + +// Update event +router.patch('/:id', authMiddleware, teamManagerMiddleware, (req, res) => { + const db = getDb(); + const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id); + if (!event) return res.status(404).json({ error: 'Not found' }); + const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds } = req.body; + db.prepare(`UPDATE events SET + title = COALESCE(?, title), event_type_id = ?, start_at = COALESCE(?, start_at), + end_at = COALESCE(?, end_at), all_day = COALESCE(?, all_day), + location = ?, description = ?, is_public = COALESCE(?, is_public), + track_availability = COALESCE(?, track_availability), updated_at = datetime('now') + WHERE id = ?`).run( + title?.trim() || null, eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id, + startAt || null, endAt || null, allDay !== undefined ? (allDay ? 1 : 0) : null, + location !== undefined ? (location || null) : event.location, + description !== undefined ? (description || null) : event.description, + isPublic !== undefined ? (isPublic ? 1 : 0) : null, + trackAvailability !== undefined ? (trackAvailability ? 1 : 0) : null, + req.params.id + ); + if (Array.isArray(userGroupIds)) { + db.prepare('DELETE FROM event_user_groups WHERE event_id = ?').run(req.params.id); + for (const ugId of userGroupIds) + db.prepare('INSERT OR IGNORE INTO event_user_groups (event_id, user_group_id) VALUES (?, ?)').run(req.params.id, ugId); + } + const updated = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id); + res.json({ event: enrichEvent(db, updated) }); +}); + +// Delete event +router.delete('/:id', authMiddleware, teamManagerMiddleware, (req, res) => { + const db = getDb(); + if (!db.prepare('SELECT id FROM events WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'Not found' }); + db.prepare('DELETE FROM events WHERE id = ?').run(req.params.id); + res.json({ success: true }); +}); + +// ── Availability ────────────────────────────────────────────────────────────── + +// Submit/update availability +router.put('/:id/availability', authMiddleware, (req, res) => { + const db = getDb(); + const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id); + if (!event) return res.status(404).json({ error: 'Not found' }); + if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled for this event' }); + const { response } = req.body; + if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' }); + // User must be in an assigned group + const inGroup = db.prepare(` + SELECT 1 FROM event_user_groups eug + JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id + WHERE eug.event_id = ? AND ugm.user_id = ? + `).get(event.id, req.user.id); + const itm = isToolManagerFn(db, req.user); + if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' }); + db.prepare(`INSERT INTO event_availability (event_id, user_id, response, updated_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(event_id, user_id) DO UPDATE SET response = ?, updated_at = datetime('now') + `).run(event.id, req.user.id, response, response); + res.json({ success: true, response }); +}); + +// Delete availability (withdraw response) +router.delete('/:id/availability', authMiddleware, (req, res) => { + const db = getDb(); + db.prepare('DELETE FROM event_availability WHERE event_id = ? AND user_id = ?').run(req.params.id, req.user.id); + res.json({ success: true }); +}); + +// Get pending availability for current user (events they need to respond to) +router.get('/me/pending', authMiddleware, (req, res) => { + const db = getDb(); + const pending = db.prepare(` + SELECT e.* FROM events e + JOIN event_user_groups eug ON eug.event_id = e.id + JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id + WHERE ugm.user_id = ? AND e.track_availability = 1 + AND e.end_at >= datetime('now') + AND NOT EXISTS (SELECT 1 FROM event_availability ea WHERE ea.event_id = e.id AND ea.user_id = ?) + ORDER BY e.start_at ASC + `).all(req.user.id, req.user.id); + res.json({ events: pending.map(e => enrichEvent(db, e)) }); +}); + +// Bulk availability response +router.post('/me/bulk-availability', authMiddleware, (req, res) => { + const { responses } = req.body; // [{ eventId, response }] + if (!Array.isArray(responses)) return res.status(400).json({ error: 'responses array required' }); + const db = getDb(); + const stmt = db.prepare(`INSERT INTO event_availability (event_id, user_id, response, updated_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(event_id, user_id) DO UPDATE SET response = ?, updated_at = datetime('now')`); + let saved = 0; + for (const { eventId, response } of responses) { + if (!['going','maybe','not_going'].includes(response)) continue; + const event = db.prepare('SELECT * FROM events WHERE id = ?').get(eventId); + if (!event || !event.track_availability) continue; + const inGroup = db.prepare(`SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id WHERE eug.event_id = ? AND ugm.user_id = ?`).get(eventId, req.user.id); + const itm = isToolManagerFn(db, req.user); + if (!inGroup && !itm) continue; + stmt.run(eventId, req.user.id, response, response); + saved++; + } + res.json({ success: true, saved }); +}); + +// ── CSV Bulk Import ─────────────────────────────────────────────────────────── + +router.post('/import/preview', authMiddleware, teamManagerMiddleware, upload.single('file'), (req, res) => { + if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); + try { + const rows = csvParse(req.file.buffer.toString('utf8'), { columns: true, skip_empty_lines: true, trim: true }); + const db = getDb(); + const results = rows.map((row, i) => { + const title = row['Event Title'] || row['event_title'] || row['title'] || ''; + const startDate = row['start_date'] || row['Start Date'] || ''; + const startTime = row['start_time'] || row['Start Time'] || '09:00'; + const location = row['event_location'] || row['location'] || ''; + const typeName = row['event_type'] || row['Event Type'] || 'Default'; + const durHrs = parseFloat(row['default_duration'] || row['duration'] || '1') || 1; + + if (!title || !startDate) return { row: i + 1, title, error: 'Missing title or start date', duplicate: false }; + + const startAt = `${startDate}T${startTime.padStart(5,'0')}:00`; + const endMs = new Date(startAt).getTime() + durHrs * 3600000; + const endAt = isNaN(endMs) ? startAt : new Date(endMs).toISOString().slice(0,19); + + // Check duplicate + const dup = db.prepare('SELECT id, title FROM events WHERE title = ? AND start_at = ?').get(title, startAt); + + return { row: i+1, title, startAt, endAt, location, typeName, durHrs, duplicate: !!dup, duplicateId: dup?.id, error: null }; + }); + res.json({ rows: results }); + } catch (e) { res.status(400).json({ error: 'CSV parse error: ' + e.message }); } +}); + +router.post('/import/confirm', authMiddleware, teamManagerMiddleware, (req, res) => { + const { rows } = req.body; // filtered rows from preview (client excludes skipped) + if (!Array.isArray(rows)) return res.status(400).json({ error: 'rows array required' }); + const db = getDb(); + let imported = 0; + const stmt = db.prepare(`INSERT INTO events (title, event_type_id, start_at, end_at, location, is_public, track_availability, created_by) + VALUES (?, ?, ?, ?, ?, 1, 0, ?)`); + for (const row of rows) { + if (row.error || row.skip) continue; + let typeId = null; + if (row.typeName) { + let et = db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?)').get(row.typeName); + if (!et) { + // Create missing type with random colour + const colours = ['#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6','#8b5cf6','#ec4899']; + const usedColours = db.prepare('SELECT colour FROM event_types').all().map(r => r.colour); + const colour = colours.find(c => !usedColours.includes(c)) || '#' + Math.floor(Math.random()*0xffffff).toString(16).padStart(6,'0'); + const r2 = db.prepare('INSERT INTO event_types (name, colour) VALUES (?, ?)').run(row.typeName, colour); + typeId = r2.lastInsertRowid; + } else { typeId = et.id; } + } + stmt.run(row.title, typeId, row.startAt, row.endAt, row.location || null, req.user.id); + imported++; + } + res.json({ success: true, imported }); +}); + +module.exports = router; diff --git a/build.sh b/build.sh index 0d88173..505970b 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.9.45}" +VERSION="${1:-0.9.46}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index c052781..d4a1bbb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.9.45", + "version": "0.9.46", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/ScheduleManagerModal.jsx b/frontend/src/components/ScheduleManagerModal.jsx new file mode 100644 index 0000000..d9ee455 --- /dev/null +++ b/frontend/src/components/ScheduleManagerModal.jsx @@ -0,0 +1,875 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import ReactDOM from 'react-dom'; +import { api } from '../utils/api.js'; +import { useToast } from '../contexts/ToastContext.jsx'; +import { useAuth } from '../contexts/AuthContext.jsx'; + +// ── Utility ─────────────────────────────────────────────────────────────────── +const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; +const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']; +const SHORT_MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + +function fmtDate(d) { return `${d.getDate()} ${SHORT_MONTHS[d.getMonth()]} ${d.getFullYear()}`; } +function fmtTime(isoStr) { + if (!isoStr) return ''; + const d = new Date(isoStr); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} +function fmtTimeRange(start, end) { return `${fmtTime(start)} – ${fmtTime(end)}`; } +function toLocalDateInput(isoStr) { return isoStr ? isoStr.slice(0,10) : ''; } +function toLocalTimeInput(isoStr) { return isoStr ? isoStr.slice(11,16) : ''; } +function buildISO(date, time) { return date && time ? `${date}T${time}:00` : ''; } +function addHours(isoStr, hrs) { + const d = new Date(isoStr); + d.setMinutes(d.getMinutes() + hrs * 60); + return d.toISOString().slice(0,19); +} +function sameDay(a, b) { + return a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate(); +} +function startOfWeek(d) { const r=new Date(d); r.setDate(d.getDate()-d.getDay()); r.setHours(0,0,0,0); return r; } +function startOfMonth(d) { return new Date(d.getFullYear(), d.getMonth(), 1); } +function daysInMonth(y,m) { return new Date(y,m+1,0).getDate(); } + +const RESPONSE_LABELS = { going: 'Going', maybe: 'Maybe', not_going: 'Not Going' }; +const RESPONSE_COLOURS = { going: '#22c55e', maybe: '#f59e0b', not_going: '#ef4444' }; + +// ── Mini Calendar ───────────────────────────────────────────────────────────── +function MiniCalendar({ selected, onChange, eventDates = new Set() }) { + const [cursor, setCursor] = useState(() => { const d = new Date(selected||Date.now()); d.setDate(1); return d; }); + const year = cursor.getFullYear(), month = cursor.getMonth(); + const firstDow = new Date(year, month, 1).getDay(); + const total = daysInMonth(year, month); + const today = new Date(); + const cells = []; + for (let i=0;i +
+ + {MONTHS[month]} {year} + +
+
+ {DAYS.map(d =>
{d[0]}
)} + {cells.map((d,i) => { + if (!d) return
; + const date = new Date(year, month, d); + const isSel = selected && sameDay(date, new Date(selected)); + const isToday = sameDay(date, today); + const hasEvent = eventDates.has(`${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`); + return ( +
onChange(date)} style={{ + textAlign:'center', padding:'3px 2px', borderRadius:4, cursor:'pointer', + background: isSel ? 'var(--primary)' : 'transparent', + color: isSel ? 'white' : isToday ? 'var(--primary)' : 'var(--text-primary)', + fontWeight: isToday ? 700 : 400, position:'relative', + }}> + {d} + {hasEvent && !isSel && } +
+ ); + })} +
+
+ ); +} + +// ── Event Type Form (popup) ─────────────────────────────────────────────────── +function EventTypePopup({ userGroups, onSave, onClose, editing = null }) { + const toast = useToast(); + const [name, setName] = useState(editing?.name || ''); + const [colour, setColour] = useState(editing?.colour || '#6366f1'); + const [defaultGroupId, setDefaultGroupId] = useState(editing?.default_user_group_id || ''); + const [defaultDur, setDefaultDur] = useState(editing?.default_duration_hrs || 1); + const [setDur, setSetDur] = useState(!!(editing?.default_duration_hrs && editing.default_duration_hrs !== 1)); + const [saving, setSaving] = useState(false); + const DUR_OPTIONS = [1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5]; + const handleSave = async () => { + if (!name.trim()) return toast('Name required', 'error'); + setSaving(true); + try { + const body = { name: name.trim(), colour, defaultUserGroupId: defaultGroupId || null, defaultDurationHrs: setDur ? defaultDur : 1 }; + const result = editing ? await api.updateEventType(editing.id, body) : await api.createEventType(body); + onSave(result.eventType); + onClose(); + } catch (e) { toast(e.message, 'error'); } + finally { setSaving(false); } + }; + return ( +
+
+ + setName(e.target.value)} style={{ marginTop:4 }} autoFocus /> +
+
+ + setColour(e.target.value)} style={{ marginTop:4, width:'100%', height:32, padding:2, borderRadius:4, border:'1px solid var(--border)' }} /> +
+
+ + +
+
+ + {setDur && ( + + )} +
+
+ + +
+
+ ); +} + +// ── Event Form ──────────────────────────────────────────────────────────────── +function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager }) { + const toast = useToast(); + const today = new Date(); + const defaultDate = selectedDate ? selectedDate.toISOString().slice(0,10) : today.toISOString().slice(0,10); + const [title, setTitle] = useState(event?.title || ''); + const [eventTypeId, setEventTypeId] = useState(event?.event_type_id || ''); + const [startDate, setStartDate] = useState(event ? toLocalDateInput(event.start_at) : defaultDate); + const [startTime, setStartTime] = useState(event ? toLocalTimeInput(event.start_at) : '09:00'); + const [endDate, setEndDate] = useState(event ? toLocalDateInput(event.end_at) : defaultDate); + const [endTime, setEndTime] = useState(event ? toLocalTimeInput(event.end_at) : '10:00'); + const [allDay, setAllDay] = useState(!!event?.all_day); + const [location, setLocation] = useState(event?.location || ''); + const [description, setDescription] = useState(event?.description || ''); + const [isPublic, setIsPublic] = useState(event ? !!event.is_public : true); + const [trackAvail, setTrackAvail] = useState(!!event?.track_availability); + const [selectedGroups, setSelectedGroups] = useState(new Set((event?.user_groups||[]).map(g=>g.id))); + const [saving, setSaving] = useState(false); + const [showTypeForm, setShowTypeForm] = useState(false); + const [localEventTypes, setLocalEventTypes] = useState(eventTypes); + const typeRef = useRef(null); + + // Auto-update end time when event type selected with default duration + useEffect(() => { + if (!eventTypeId || event) return; + const et = localEventTypes.find(t => t.id === Number(eventTypeId)); + if (!et || !startDate || !startTime) return; + const start = buildISO(startDate, startTime); + setEndDate(toLocalDateInput(addHours(start, et.default_duration_hrs))); + setEndTime(toLocalTimeInput(addHours(start, et.default_duration_hrs))); + if (et.default_user_group_id && !event) setSelectedGroups(prev => new Set([...prev, et.default_user_group_id])); + }, [eventTypeId]); + + const toggleGroup = (id) => setSelectedGroups(prev => { const n=new Set(prev); n.has(id)?n.delete(id):n.add(id); return n; }); + + const handleSave = async () => { + if (!title.trim()) return toast('Title required', 'error'); + if (!allDay && (!startDate||!startTime||!endDate||!endTime)) return toast('Start and end required', 'error'); + setSaving(true); + try { + const body = { + title: title.trim(), eventTypeId: eventTypeId || null, + startAt: allDay ? `${startDate}T00:00:00` : buildISO(startDate, startTime), + endAt: allDay ? `${endDate}T23:59:59` : buildISO(endDate, endTime), + allDay, location, description, isPublic, trackAvailability: trackAvail, + userGroupIds: [...selectedGroups], + }; + const result = event ? await api.updateEvent(event.id, body) : await api.createEvent(body); + onSave(result.event); + } catch (e) { toast(e.message, 'error'); } + finally { setSaving(false); } + }; + + const Row = ({ label, children }) => ( +
+
{label}
+
{children}
+
+ ); + + return ( +
+ {/* Title */} + setTitle(e.target.value)} + style={{ fontSize:18, fontWeight:600, marginBottom:16, border:'none', borderBottom:'2px solid var(--border)', borderRadius:0, padding:'4px 0' }} /> + + {/* Date/Time */} + +
+ setStartDate(e.target.value)} style={{ width:150 }} /> + {!allDay && setStartTime(e.target.value)} style={{ width:120 }} />} + to + {!allDay && setEndTime(e.target.value)} style={{ width:120 }} />} + setEndDate(e.target.value)} style={{ width:150 }} /> +
+ +
+ + {/* Event Type */} + +
+ + {isToolManager && ( + + )} + {showTypeForm && ( + setLocalEventTypes(prev=>[...prev,et])} onClose={()=>setShowTypeForm(false)} /> + )} +
+
+ + {/* Groups */} + +
+ {userGroups.length === 0 ? ( +
No user groups created yet
+ ) : userGroups.map(g => ( + + ))} +
+
+ {selectedGroups.size === 0 ? 'No groups — event visible to all (if public)' : `${selectedGroups.size} group${selectedGroups.size!==1?'s':''} selected`} +
+
+ + {/* Visibility + Availability */} + +
+ + +
+
+ + {/* Location */} + + setLocation(e.target.value)} /> + + + {/* Description */} + +