v0.9.46 Add event scheduler
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
327
backend/src/routes/schedule.js
Normal file
327
backend/src/routes/schedule.js
Normal file
@@ -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;
|
||||
2
build.sh
2
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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-frontend",
|
||||
"version": "0.9.45",
|
||||
"version": "0.9.46",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
875
frontend/src/components/ScheduleManagerModal.jsx
Normal file
875
frontend/src/components/ScheduleManagerModal.jsx
Normal file
@@ -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<firstDow;i++) cells.push(null);
|
||||
for (let d=1;d<=total;d++) cells.push(d);
|
||||
return (
|
||||
<div style={{ userSelect: 'none' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:8, fontSize:13, fontWeight:600 }}>
|
||||
<button style={{ background:'none',border:'none',cursor:'pointer',padding:'2px 6px',color:'var(--text-secondary)'}} onClick={() => { const n=new Date(cursor); n.setMonth(n.getMonth()-1); setCursor(n); }}>‹</button>
|
||||
<span>{MONTHS[month]} {year}</span>
|
||||
<button style={{ background:'none',border:'none',cursor:'pointer',padding:'2px 6px',color:'var(--text-secondary)'}} onClick={() => { const n=new Date(cursor); n.setMonth(n.getMonth()+1); setCursor(n); }}>›</button>
|
||||
</div>
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(7,1fr)', gap:1, fontSize:11 }}>
|
||||
{DAYS.map(d => <div key={d} style={{ textAlign:'center', fontWeight:600, color:'var(--text-tertiary)', padding:'2px 0' }}>{d[0]}</div>)}
|
||||
{cells.map((d,i) => {
|
||||
if (!d) return <div key={i}/>;
|
||||
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 (
|
||||
<div key={i} onClick={() => 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 && <span style={{ position:'absolute', bottom:1, left:'50%', transform:'translateX(-50%)', width:4, height:4, borderRadius:'50%', background:'var(--primary)', display:'block' }}/>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div style={{ position:'absolute', top:'100%', left:0, zIndex:200, background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius)', padding:16, width:280, boxShadow:'0 4px 20px rgba(0,0,0,0.15)' }}>
|
||||
<div style={{ marginBottom:10 }}>
|
||||
<label className="settings-section-label">Type Name</label>
|
||||
<input className="input" value={name} onChange={e=>setName(e.target.value)} style={{ marginTop:4 }} autoFocus />
|
||||
</div>
|
||||
<div style={{ marginBottom:10 }}>
|
||||
<label className="settings-section-label">Colour</label>
|
||||
<input type="color" value={colour} onChange={e=>setColour(e.target.value)} style={{ marginTop:4, width:'100%', height:32, padding:2, borderRadius:4, border:'1px solid var(--border)' }} />
|
||||
</div>
|
||||
<div style={{ marginBottom:10 }}>
|
||||
<label className="settings-section-label">Default Group</label>
|
||||
<select className="input" value={defaultGroupId} onChange={e=>setDefaultGroupId(e.target.value)} style={{ marginTop:4 }}>
|
||||
<option value="">None</option>
|
||||
{userGroups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ marginBottom:14 }}>
|
||||
<label style={{ display:'flex', alignItems:'center', gap:8, fontSize:13, cursor:'pointer' }}>
|
||||
<input type="checkbox" checked={setDur} onChange={e=>setSetDur(e.target.checked)} /> Set default duration
|
||||
</label>
|
||||
{setDur && (
|
||||
<select className="input" value={defaultDur} onChange={e=>setDefaultDur(Number(e.target.value))} style={{ marginTop:6 }}>
|
||||
{DUR_OPTIONS.map(d => <option key={d} value={d}>{d}hr{d!==1?'s':''}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display:'flex', gap:8 }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={saving}>{saving?'Saving…':'Save'}</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 }) => (
|
||||
<div style={{ display:'flex', alignItems:'flex-start', gap:16, marginBottom:14 }}>
|
||||
<div style={{ width:80, flexShrink:0, fontSize:13, color:'var(--text-tertiary)', paddingTop:8 }}>{label}</div>
|
||||
<div style={{ flex:1 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display:'flex', flexDirection:'column', gap:0 }}>
|
||||
{/* Title */}
|
||||
<input className="input" placeholder="Add title" value={title} onChange={e=>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 */}
|
||||
<Row label="">
|
||||
<div style={{ display:'flex', flexWrap:'wrap', gap:8, alignItems:'center' }}>
|
||||
<input type="date" className="input" value={startDate} onChange={e=>setStartDate(e.target.value)} style={{ width:150 }} />
|
||||
{!allDay && <input type="time" className="input" value={startTime} onChange={e=>setStartTime(e.target.value)} style={{ width:120 }} />}
|
||||
<span style={{ color:'var(--text-tertiary)', fontSize:13 }}>to</span>
|
||||
{!allDay && <input type="time" className="input" value={endTime} onChange={e=>setEndTime(e.target.value)} style={{ width:120 }} />}
|
||||
<input type="date" className="input" value={endDate} onChange={e=>setEndDate(e.target.value)} style={{ width:150 }} />
|
||||
</div>
|
||||
<label style={{ display:'flex', alignItems:'center', gap:8, marginTop:8, fontSize:13, cursor:'pointer' }}>
|
||||
<input type="checkbox" checked={allDay} onChange={e=>setAllDay(e.target.checked)} /> All day
|
||||
</label>
|
||||
</Row>
|
||||
|
||||
{/* Event Type */}
|
||||
<Row label="Event Type">
|
||||
<div style={{ display:'flex', gap:8, alignItems:'center', position:'relative' }} ref={typeRef}>
|
||||
<select className="input flex-1" value={eventTypeId} onChange={e=>setEventTypeId(e.target.value)}>
|
||||
<option value="">Default</option>
|
||||
{localEventTypes.filter(t=>!t.is_default).map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{isToolManager && (
|
||||
<button className="btn btn-secondary btn-sm" onClick={()=>setShowTypeForm(v=>!v)} style={{ flexShrink:0 }}>
|
||||
{showTypeForm ? 'Cancel' : '+ Add Type'}
|
||||
</button>
|
||||
)}
|
||||
{showTypeForm && (
|
||||
<EventTypePopup userGroups={userGroups} onSave={et => setLocalEventTypes(prev=>[...prev,et])} onClose={()=>setShowTypeForm(false)} />
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Groups */}
|
||||
<Row label="Groups">
|
||||
<div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius)', overflow:'hidden', maxHeight:160, overflowY:'auto' }}>
|
||||
{userGroups.length === 0 ? (
|
||||
<div style={{ padding:'10px 14px', fontSize:13, color:'var(--text-tertiary)' }}>No user groups created yet</div>
|
||||
) : userGroups.map(g => (
|
||||
<label key={g.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'7px 12px', borderBottom:'1px solid var(--border)', cursor:'pointer', fontSize:13 }}>
|
||||
<input type="checkbox" checked={selectedGroups.has(g.id)} onChange={()=>toggleGroup(g.id)} style={{ accentColor:'var(--primary)' }} />
|
||||
{g.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize:11, color:'var(--text-tertiary)', marginTop:4 }}>
|
||||
{selectedGroups.size === 0 ? 'No groups — event visible to all (if public)' : `${selectedGroups.size} group${selectedGroups.size!==1?'s':''} selected`}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Visibility + Availability */}
|
||||
<Row label="Options">
|
||||
<div style={{ display:'flex', flexDirection:'column', gap:8 }}>
|
||||
<label style={{ display:'flex', alignItems:'center', gap:10, fontSize:13, cursor:'pointer' }}>
|
||||
<input type="checkbox" checked={!isPublic} onChange={e=>setIsPublic(!e.target.checked)} />
|
||||
<span>Viewable by selected groups only</span>
|
||||
</label>
|
||||
<label style={{ display:'flex', alignItems:'center', gap:10, fontSize:13, cursor:'pointer' }}>
|
||||
<input type="checkbox" checked={trackAvail} onChange={e=>setTrackAvail(e.target.checked)} />
|
||||
<span>Track availability for assigned groups</span>
|
||||
</label>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Location */}
|
||||
<Row label="Location">
|
||||
<input className="input" placeholder="Add location" value={location} onChange={e=>setLocation(e.target.value)} />
|
||||
</Row>
|
||||
|
||||
{/* Description */}
|
||||
<Row label="Description">
|
||||
<textarea className="input" placeholder="Add description" value={description} onChange={e=>setDescription(e.target.value)} rows={3} style={{ resize:'vertical' }} />
|
||||
</Row>
|
||||
|
||||
<div style={{ display:'flex', gap:8, marginTop:8 }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={saving}>{saving?'Saving…':event?'Save Changes':'Create Event'}</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
|
||||
{event && isToolManager && (
|
||||
<button className="btn btn-sm" style={{ marginLeft:'auto', background:'var(--error)', color:'white' }} onClick={()=>onDelete(event)}>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Event Detail Popup ────────────────────────────────────────────────────────
|
||||
function EventDetailPopup({ event, onClose, onEdit, onAvailabilityChange, isToolManager, currentUserId }) {
|
||||
const toast = useToast();
|
||||
const [myResponse, setMyResponse] = useState(event.my_response);
|
||||
const [avail, setAvail] = useState(event.availability || []);
|
||||
const canSeeAvail = isToolManager || (event.user_groups||[]).some(g => {
|
||||
// Check if current user is in assigned group — simplified: trust event.my_response existing
|
||||
return true; // backend already filtered; if they can view, they're in the group
|
||||
});
|
||||
const isInGroup = event.track_availability && (isToolManager || event.user_groups?.length > 0);
|
||||
|
||||
const handleResponse = async (resp) => {
|
||||
try {
|
||||
if (myResponse === resp) {
|
||||
await api.deleteAvailability(event.id);
|
||||
setMyResponse(null);
|
||||
} else {
|
||||
await api.setAvailability(event.id, resp);
|
||||
setMyResponse(resp);
|
||||
}
|
||||
onAvailabilityChange?.();
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const counts = { going: 0, maybe: 0, not_going: 0 };
|
||||
avail.forEach(r => { if (counts[r.response] !== undefined) counts[r.response]++; });
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onClose()}>
|
||||
<div className="modal" style={{ maxWidth:520 }}>
|
||||
<div style={{ display:'flex', alignItems:'flex-start', justifyContent:'space-between', marginBottom:16 }}>
|
||||
<div style={{ flex:1, paddingRight:16 }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:4 }}>
|
||||
{event.event_type && (
|
||||
<span style={{ width:12, height:12, borderRadius:'50%', background:event.event_type.colour, flexShrink:0, display:'inline-block' }}/>
|
||||
)}
|
||||
<h2 style={{ fontSize:20, fontWeight:700, margin:0 }}>{event.title}</h2>
|
||||
</div>
|
||||
<div style={{ fontSize:13, color:'var(--text-secondary)' }}>
|
||||
{event.event_type?.name && <span style={{ marginRight:8 }}>{event.event_type.name}</span>}
|
||||
{!event.is_public && <span style={{ background:'var(--surface-variant)', borderRadius:10, padding:'1px 8px', fontSize:11 }}>Private</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display:'flex', gap:6 }}>
|
||||
{isToolManager && <button className="btn btn-secondary btn-sm" onClick={onEdit}>Edit</button>}
|
||||
<button className="btn-icon" onClick={onClose}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date/Time */}
|
||||
<div style={{ display:'flex', gap:8, alignItems:'center', marginBottom:12, fontSize:14 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<span>{fmtDate(new Date(event.start_at))}{!event.all_day && ` · ${fmtTimeRange(event.start_at, event.end_at)}`}</span>
|
||||
</div>
|
||||
|
||||
{event.location && (
|
||||
<div style={{ display:'flex', gap:8, alignItems:'center', marginBottom:12, fontSize:14 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
<span>{event.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.description && (
|
||||
<div style={{ display:'flex', gap:8, marginBottom:12, fontSize:14 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{ flexShrink:0, marginTop:2 }}><line x1="21" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/></svg>
|
||||
<span style={{ whiteSpace:'pre-wrap' }}>{event.description}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(event.user_groups||[]).length > 0 && (
|
||||
<div style={{ display:'flex', gap:8, marginBottom:16, fontSize:13, color:'var(--text-secondary)' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{ flexShrink:0, marginTop:2 }}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/></svg>
|
||||
<span>{event.user_groups.map(g=>g.name).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Availability response buttons */}
|
||||
{event.track_availability && (
|
||||
<div style={{ borderTop:'1px solid var(--border)', paddingTop:14, marginTop:4 }}>
|
||||
<div style={{ fontSize:12, fontWeight:600, color:'var(--text-tertiary)', textTransform:'uppercase', letterSpacing:'0.5px', marginBottom:8 }}>Your Availability</div>
|
||||
<div style={{ display:'flex', gap:8 }}>
|
||||
{Object.entries(RESPONSE_LABELS).map(([key, label]) => (
|
||||
<button key={key} onClick={()=>handleResponse(key)} className="btn btn-sm"
|
||||
style={{ flex:1, background: myResponse===key ? RESPONSE_COLOURS[key] : 'var(--surface-variant)', color: myResponse===key ? 'white' : 'var(--text-primary)', borderColor: RESPONSE_COLOURS[key] }}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Availability breakdown (tool managers + assigned group members) */}
|
||||
{isToolManager && avail.length >= 0 && (
|
||||
<div style={{ marginTop:14 }}>
|
||||
<div style={{ fontSize:12, fontWeight:600, color:'var(--text-tertiary)', textTransform:'uppercase', letterSpacing:'0.5px', marginBottom:8 }}>Responses</div>
|
||||
<div style={{ display:'flex', gap:16, marginBottom:8 }}>
|
||||
{Object.entries(counts).map(([key, n]) => (
|
||||
<span key={key} style={{ fontSize:13 }}>
|
||||
<span style={{ color:RESPONSE_COLOURS[key], fontWeight:600 }}>{n}</span> {RESPONSE_LABELS[key]}
|
||||
</span>
|
||||
))}
|
||||
{event.no_response_count > 0 && (
|
||||
<span style={{ fontSize:13 }}><span style={{ fontWeight:600 }}>{event.no_response_count}</span> No response</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ maxHeight:140, overflowY:'auto' }}>
|
||||
{avail.map(r => (
|
||||
<div key={r.user_id} style={{ display:'flex', alignItems:'center', gap:8, padding:'4px 0', fontSize:13 }}>
|
||||
<span style={{ width:8, height:8, borderRadius:'50%', background:RESPONSE_COLOURS[r.response], flexShrink:0, display:'inline-block' }}/>
|
||||
<span style={{ flex:1 }}>{r.display_name || r.name}</span>
|
||||
<span style={{ color:RESPONSE_COLOURS[r.response], fontSize:12 }}>{RESPONSE_LABELS[r.response]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
// ── Schedule (List) View ──────────────────────────────────────────────────────
|
||||
function ScheduleView({ events, selectedDate, onSelectEvent }) {
|
||||
const filtered = events.filter(e => new Date(e.end_at) >= (selectedDate || new Date(0)));
|
||||
return (
|
||||
<div style={{ overflowY:'auto', flex:1 }}>
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ textAlign:'center', padding:'40px 20px', color:'var(--text-tertiary)', fontSize:14 }}>No upcoming events</div>
|
||||
)}
|
||||
{filtered.map(e => {
|
||||
const start = new Date(e.start_at);
|
||||
const colour = e.event_type?.colour || '#9ca3af';
|
||||
return (
|
||||
<div key={e.id} onClick={()=>onSelectEvent(e)} style={{ display:'flex', alignItems:'center', gap:16, padding:'12px 16px', borderBottom:'1px solid var(--border)', cursor:'pointer', transition:'background var(--transition)' }}
|
||||
onMouseEnter={el=>el.currentTarget.style.background='var(--background)'}
|
||||
onMouseLeave={el=>el.currentTarget.style.background=''}>
|
||||
<div style={{ width:40, textAlign:'center', flexShrink:0 }}>
|
||||
<div style={{ fontSize:18, fontWeight:700, lineHeight:1 }}>{start.getDate()}</div>
|
||||
<div style={{ fontSize:11, color:'var(--text-tertiary)', textTransform:'uppercase' }}>{SHORT_MONTHS[start.getMonth()]}, {DAYS[start.getDay()]}</div>
|
||||
</div>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:8, flexShrink:0, width:90, fontSize:13, color:'var(--text-secondary)' }}>
|
||||
<span style={{ width:10, height:10, borderRadius:'50%', background:colour, flexShrink:0 }}/>
|
||||
{e.all_day ? 'All day' : `${fmtTime(e.start_at)} – ${fmtTime(e.end_at)}`}
|
||||
</div>
|
||||
<div style={{ flex:1, minWidth:0 }}>
|
||||
<div style={{ fontSize:14, fontWeight:600, display:'flex', alignItems:'center', gap:8 }}>
|
||||
{e.event_type?.name && <span style={{ fontSize:12, color:'var(--text-tertiary)', textTransform:'uppercase', letterSpacing:'0.5px' }}>{e.event_type.name}:</span>}
|
||||
{e.title}
|
||||
{e.track_availability && !e.my_response && (
|
||||
<span style={{ width:8, height:8, borderRadius:'50%', background:'#ef4444', flexShrink:0 }} title="Awaiting your response"/>
|
||||
)}
|
||||
</div>
|
||||
{e.location && <div style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:2 }}>{e.location}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Day View ──────────────────────────────────────────────────────────────────
|
||||
function DayView({ events, selectedDate, onSelectEvent }) {
|
||||
const hours = Array.from({length:16}, (_,i)=>i+7); // 7am–10pm
|
||||
const dayEvents = events.filter(e => sameDay(new Date(e.start_at), selectedDate));
|
||||
return (
|
||||
<div style={{ overflowY:'auto', flex:1, position:'relative' }}>
|
||||
<div style={{ display:'flex', borderBottom:'1px solid var(--border)', padding:'8px 0 8px 52px', fontSize:13, fontWeight:600, color:'var(--primary)' }}>
|
||||
<div style={{ textAlign:'center' }}>
|
||||
<div>{DAYS[selectedDate.getDay()]}</div>
|
||||
<div style={{ fontSize:24, fontWeight:700 }}>{selectedDate.getDate()}</div>
|
||||
</div>
|
||||
</div>
|
||||
{hours.map(h => (
|
||||
<div key={h} style={{ display:'flex', borderBottom:'1px solid var(--border)', minHeight:48 }}>
|
||||
<div style={{ width:52, flexShrink:0, fontSize:11, color:'var(--text-tertiary)', padding:'2px 8px 0', textAlign:'right' }}>{h > 12 ? `${h-12} PM` : h === 12 ? '12 PM' : `${h} AM`}</div>
|
||||
<div style={{ flex:1, position:'relative' }}>
|
||||
{dayEvents.filter(e => new Date(e.start_at).getHours()===h).map(e => (
|
||||
<div key={e.id} onClick={()=>onSelectEvent(e)} style={{
|
||||
margin:'2px 4px', padding:'4px 8px', borderRadius:4,
|
||||
background: e.event_type?.colour || '#6366f1', color:'white',
|
||||
fontSize:12, cursor:'pointer', fontWeight:600
|
||||
}}>
|
||||
{e.title} · {fmtTimeRange(e.start_at, e.end_at)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Week View ─────────────────────────────────────────────────────────────────
|
||||
function WeekView({ events, selectedDate, onSelectEvent }) {
|
||||
const weekStart = startOfWeek(selectedDate);
|
||||
const days = Array.from({length:7}, (_,i) => { const d=new Date(weekStart); d.setDate(d.getDate()+i); return d; });
|
||||
const hours = Array.from({length:16}, (_,i)=>i+7);
|
||||
const today = new Date();
|
||||
return (
|
||||
<div style={{ overflowY:'auto', flex:1 }}>
|
||||
<div style={{ display:'grid', gridTemplateColumns:'52px repeat(7,1fr)', borderBottom:'1px solid var(--border)' }}>
|
||||
<div/>
|
||||
{days.map((d,i) => (
|
||||
<div key={i} style={{ textAlign:'center', padding:'6px 4px', fontSize:12, fontWeight:600, color:sameDay(d,today)?'var(--primary)':'var(--text-secondary)' }}>
|
||||
{DAYS[d.getDay()]} {d.getDate()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hours.map(h => (
|
||||
<div key={h} style={{ display:'grid', gridTemplateColumns:'52px repeat(7,1fr)', borderBottom:'1px solid var(--border)', minHeight:44 }}>
|
||||
<div style={{ fontSize:11, color:'var(--text-tertiary)', padding:'2px 8px 0', textAlign:'right' }}>
|
||||
{h>12?`${h-12} PM`:h===12?'12 PM':`${h} AM`}
|
||||
</div>
|
||||
{days.map((d,i) => (
|
||||
<div key={i} style={{ borderLeft:'1px solid var(--border)', padding:'1px 2px' }}>
|
||||
{events.filter(e=>sameDay(new Date(e.start_at),d)&&new Date(e.start_at).getHours()===h).map(e=>(
|
||||
<div key={e.id} onClick={()=>onSelectEvent(e)} style={{ background:e.event_type?.colour||'#6366f1', color:'white', borderRadius:3, padding:'2px 4px', fontSize:11, cursor:'pointer', marginBottom:1, fontWeight:600 }}>
|
||||
{e.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Month View ────────────────────────────────────────────────────────────────
|
||||
function MonthView({ events, selectedDate, onSelectEvent, onSelectDay }) {
|
||||
const year = selectedDate.getFullYear(), month = selectedDate.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<firstDow;i++) cells.push(null);
|
||||
for (let d=1;d<=total;d++) cells.push(d);
|
||||
while (cells.length % 7 !== 0) cells.push(null);
|
||||
const weeks = [];
|
||||
for (let i=0;i<cells.length;i+=7) weeks.push(cells.slice(i,i+7));
|
||||
return (
|
||||
<div style={{ flex:1, overflowY:'auto', display:'flex', flexDirection:'column' }}>
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(7,1fr)', borderBottom:'1px solid var(--border)' }}>
|
||||
{DAYS.map(d=><div key={d} style={{ textAlign:'center', padding:'6px', fontSize:12, fontWeight:600, color:'var(--text-tertiary)' }}>{d}</div>)}
|
||||
</div>
|
||||
{weeks.map((week,wi)=>(
|
||||
<div key={wi} style={{ display:'grid', gridTemplateColumns:'repeat(7,1fr)', flex:1, minHeight:80 }}>
|
||||
{week.map((d,di)=>{
|
||||
if (!d) return <div key={di} style={{ borderRight:'1px solid var(--border)', borderBottom:'1px solid var(--border)', background:'var(--surface-variant)' }}/>;
|
||||
const date = new Date(year, month, d);
|
||||
const dayEvents = events.filter(e=>sameDay(new Date(e.start_at),date));
|
||||
const isToday = sameDay(date, today);
|
||||
return (
|
||||
<div key={di} onClick={()=>onSelectDay(date)} style={{ borderRight:'1px solid var(--border)', borderBottom:'1px solid var(--border)', padding:'4px', cursor:'pointer', minHeight:80 }}
|
||||
onMouseEnter={el=>el.currentTarget.style.background='var(--background)'}
|
||||
onMouseLeave={el=>el.currentTarget.style.background=''}>
|
||||
<div style={{ width:24, height:24, borderRadius:'50%', display:'flex', alignItems:'center', justifyContent:'center', marginBottom:2, fontSize:12, fontWeight:isToday?700:400, background:isToday?'var(--primary)':'transparent', color:isToday?'white':'var(--text-primary)' }}>{d}</div>
|
||||
{dayEvents.slice(0,3).map(e=>(
|
||||
<div key={e.id} onClick={ev=>{ev.stopPropagation();onSelectEvent(e);}} style={{ background:e.event_type?.colour||'#6366f1', color:'white', borderRadius:2, padding:'1px 5px', fontSize:11, marginBottom:1, truncate:true, cursor:'pointer', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>
|
||||
{!e.all_day&&<span style={{ marginRight:3 }}>{fmtTime(e.start_at)}</span>}{e.title}
|
||||
</div>
|
||||
))}
|
||||
{dayEvents.length>3&&<div style={{ fontSize:10, color:'var(--text-tertiary)' }}>+{dayEvents.length-3} more</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bulk Import ───────────────────────────────────────────────────────────────
|
||||
function BulkImportPanel({ onImported, onCancel }) {
|
||||
const toast = useToast();
|
||||
const [rows, setRows] = useState(null);
|
||||
const [skipped, setSkipped] = useState(new Set());
|
||||
const [importing, setSaving] = useState(false);
|
||||
const handleFile = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const result = await api.importPreview(file);
|
||||
if (result.error) return toast(result.error, 'error');
|
||||
setRows(result.rows);
|
||||
setSkipped(new Set(result.rows.filter(r=>r.duplicate||r.error).map(r=>r.row)));
|
||||
} catch (err) { toast('Upload failed', 'error'); }
|
||||
};
|
||||
const handleImport = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const toImport = rows.filter(r => !skipped.has(r.row) && !r.error);
|
||||
const { imported } = await api.importConfirm(toImport);
|
||||
toast(`${imported} event${imported!==1?'s':''} imported`, 'success');
|
||||
onImported();
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div className="settings-section-label">Bulk Event Import</div>
|
||||
<p style={{ fontSize:12, color:'var(--text-tertiary)', marginBottom:12 }}>
|
||||
CSV fields: <code>Event Title, start_date (YYYY-MM-DD), start_time (HH:MM), event_location, event_type, default_duration</code>
|
||||
</p>
|
||||
<input type="file" accept=".csv" onChange={handleFile} style={{ marginBottom:16 }} />
|
||||
{rows && (
|
||||
<>
|
||||
<div style={{ overflowX:'auto', marginBottom:12 }}>
|
||||
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:12 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom:'2px solid var(--border)' }}>
|
||||
{['','Row','Title','Start','End','Type','Duration','Status'].map(h=>(
|
||||
<th key={h} style={{ padding:'4px 8px', textAlign:'left', color:'var(--text-tertiary)', whiteSpace:'nowrap' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(r=>(
|
||||
<tr key={r.row} style={{ borderBottom:'1px solid var(--border)', opacity:skipped.has(r.row)?0.4:1 }}>
|
||||
<td style={{ padding:'4px 8px' }}>
|
||||
<input type="checkbox" checked={!skipped.has(r.row)} disabled={!!r.error}
|
||||
onChange={()=>setSkipped(prev=>{const n=new Set(prev);n.has(r.row)?n.delete(r.row):n.add(r.row);return n;})} />
|
||||
</td>
|
||||
<td style={{ padding:'4px 8px' }}>{r.row}</td>
|
||||
<td style={{ padding:'4px 8px', fontWeight:600 }}>{r.title}</td>
|
||||
<td style={{ padding:'4px 8px' }}>{r.startAt?.slice(0,16).replace('T',' ')}</td>
|
||||
<td style={{ padding:'4px 8px' }}>{r.endAt?.slice(0,16).replace('T',' ')}</td>
|
||||
<td style={{ padding:'4px 8px' }}>{r.typeName}</td>
|
||||
<td style={{ padding:'4px 8px' }}>{r.durHrs}hr</td>
|
||||
<td style={{ padding:'4px 8px' }}>
|
||||
{r.error ? <span style={{ color:'var(--error)' }}>{r.error}</span>
|
||||
: r.duplicate ? <span style={{ color:'#f59e0b' }}>⚠ Duplicate</span>
|
||||
: <span style={{ color:'var(--success)' }}>✓ Ready</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ display:'flex', gap:8, alignItems:'center' }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleImport} disabled={importing}>
|
||||
{importing ? 'Importing…' : `Import ${rows.filter(r=>!skipped.has(r.row)&&!r.error).length} events`}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Event Types Manager ───────────────────────────────────────────────────────
|
||||
function EventTypesPanel({ eventTypes, userGroups, onUpdated, onClose }) {
|
||||
const toast = useToast();
|
||||
const [editingType, setEditingType] = useState(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
const handleDelete = async (et) => {
|
||||
if (!confirm(`Delete event type "${et.name}"? Existing events will lose their type.`)) return;
|
||||
try {
|
||||
await api.deleteEventType(et.id);
|
||||
toast('Event type deleted', 'success');
|
||||
onUpdated();
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:16 }}>
|
||||
<div className="settings-section-label" style={{ margin:0 }}>Event Types</div>
|
||||
<div style={{ position:'relative' }} ref={ref}>
|
||||
<button className="btn btn-primary btn-sm" onClick={()=>{setShowForm(v=>!v);setEditingType(null);}}>+ New Type</button>
|
||||
{showForm && !editingType && (
|
||||
<EventTypePopup userGroups={userGroups} onSave={()=>onUpdated()} onClose={()=>setShowForm(false)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display:'flex', flexDirection:'column', gap:6 }}>
|
||||
{eventTypes.map(et => (
|
||||
<div key={et.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 12px', border:'1px solid var(--border)', borderRadius:'var(--radius)' }}>
|
||||
<span style={{ width:16, height:16, borderRadius:'50%', background:et.colour, flexShrink:0 }}/>
|
||||
<span style={{ flex:1, fontSize:14, fontWeight:500 }}>{et.name}</span>
|
||||
{et.default_duration_hrs > 1 && <span style={{ fontSize:12, color:'var(--text-tertiary)' }}>{et.default_duration_hrs}hr default</span>}
|
||||
{!et.is_default && (
|
||||
<>
|
||||
<div style={{ position:'relative' }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={()=>{setEditingType(et);setShowForm(true);}}>Edit</button>
|
||||
{showForm && editingType?.id===et.id && (
|
||||
<EventTypePopup editing={et} userGroups={userGroups} onSave={()=>{onUpdated();setShowForm(false);setEditingType(null);}} onClose={()=>{setShowForm(false);setEditingType(null);}} />
|
||||
)}
|
||||
</div>
|
||||
<button className="btn btn-sm" style={{ background:'var(--error)', color:'white' }} onClick={()=>handleDelete(et)}>Delete</button>
|
||||
</>
|
||||
)}
|
||||
{et.is_default && <span style={{ fontSize:11, color:'var(--text-tertiary)' }}>Default</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Modal ────────────────────────────────────────────────────────────────
|
||||
export default function ScheduleManagerModal({ onClose, isToolManager }) {
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const [view, setView] = useState('schedule'); // schedule | day | week | month
|
||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||
const [events, setEvents] = useState([]);
|
||||
const [eventTypes, setEventTypes] = useState([]);
|
||||
const [userGroups, setUserGroups] = useState([]);
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const [rightPanel, setRightPanel] = useState('calendar'); // calendar | eventForm | eventTypes | bulkImport
|
||||
const [editingEvent, setEditingEvent] = useState(null);
|
||||
const [createMenuOpen, setCreateMenuOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const createRef = useRef(null);
|
||||
|
||||
const loadAll = useCallback(() => {
|
||||
Promise.all([
|
||||
api.getEvents(),
|
||||
api.getEventTypes(),
|
||||
api.getUserGroups(),
|
||||
]).then(([ev, et, ug]) => {
|
||||
setEvents(ev.events || []);
|
||||
setEventTypes(et.eventTypes || []);
|
||||
setUserGroups(ug.groups || []);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadAll(); }, [loadAll]);
|
||||
|
||||
// Close create menu on outside click
|
||||
useEffect(() => {
|
||||
if (!createMenuOpen) return;
|
||||
const h = e => { if (createRef.current && !createRef.current.contains(e.target)) setCreateMenuOpen(false); };
|
||||
document.addEventListener('mousedown', h);
|
||||
return () => document.removeEventListener('mousedown', h);
|
||||
}, [createMenuOpen]);
|
||||
|
||||
const eventDates = new Set(events.map(e => e.start_at?.slice(0,10)));
|
||||
|
||||
const navDate = (dir) => {
|
||||
const d = new Date(selectedDate);
|
||||
if (view === 'day') d.setDate(d.getDate() + dir);
|
||||
else if (view === 'week') d.setDate(d.getDate() + dir*7);
|
||||
else if (view === 'month') d.setMonth(d.getMonth() + dir);
|
||||
else d.setDate(d.getDate() + dir*7);
|
||||
setSelectedDate(d);
|
||||
};
|
||||
|
||||
const navLabel = () => {
|
||||
if (view === 'day') return `${DAYS[selectedDate.getDay()]} ${selectedDate.getDate()} ${MONTHS[selectedDate.getMonth()]} ${selectedDate.getFullYear()}`;
|
||||
if (view === 'week') { const ws=startOfWeek(selectedDate); const we=new Date(ws); we.setDate(we.getDate()+6); return `${SHORT_MONTHS[ws.getMonth()]} ${ws.getDate()} – ${SHORT_MONTHS[we.getMonth()]} ${we.getDate()} ${we.getFullYear()}`; }
|
||||
return `${MONTHS[selectedDate.getMonth()]} ${selectedDate.getFullYear()}`;
|
||||
};
|
||||
|
||||
const openEventDetail = async (e) => {
|
||||
try {
|
||||
const { event } = await api.getEvent(e.id);
|
||||
setSelectedEvent(event);
|
||||
} catch (err) { toast('Failed to load event', 'error'); }
|
||||
};
|
||||
|
||||
const handleEventSaved = () => {
|
||||
loadAll();
|
||||
setRightPanel('calendar');
|
||||
setEditingEvent(null);
|
||||
};
|
||||
|
||||
const handleDeleteEvent = async (e) => {
|
||||
if (!confirm(`Delete "${e.title}"?`)) return;
|
||||
try { await api.deleteEvent(e.id); toast('Event deleted', 'success'); loadAll(); setSelectedEvent(null); } catch (err) { toast(err.message, 'error'); }
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="modal-overlay"><div className="modal" style={{ maxWidth:200, textAlign:'center' }}>Loading…</div></div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onClose()}>
|
||||
<div className="modal" style={{ maxWidth:1024, width:'96vw', height:'90vh', display:'flex', flexDirection:'column', padding:0, overflow:'hidden' }}>
|
||||
{/* Header */}
|
||||
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', padding:'12px 20px', borderBottom:'1px solid var(--border)', flexShrink:0, gap:12 }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:12 }}>
|
||||
{/* Create dropdown */}
|
||||
{isToolManager && (
|
||||
<div style={{ position:'relative' }} ref={createRef}>
|
||||
<button className="btn btn-primary btn-sm" onClick={()=>setCreateMenuOpen(v=>!v)} style={{ display:'flex', alignItems:'center', gap:6 }}>
|
||||
+ Create
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
{createMenuOpen && (
|
||||
<div style={{ position:'absolute', top:'100%', left:0, zIndex:100, background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius)', marginTop:4, minWidth:180, boxShadow:'0 4px 16px rgba(0,0,0,0.12)' }}>
|
||||
{[['Event', ()=>{setRightPanel('eventForm');setEditingEvent(null);setCreateMenuOpen(false);}],
|
||||
['Event Type', ()=>{setRightPanel('eventTypes');setCreateMenuOpen(false);}],
|
||||
['Bulk Event Import', ()=>{setRightPanel('bulkImport');setCreateMenuOpen(false);}]
|
||||
].map(([label, action]) => (
|
||||
<button key={label} onClick={action} style={{ display:'block', width:'100%', padding:'9px 14px', textAlign:'left', fontSize:14, background:'none', border:'none', cursor:'pointer', color:'var(--text-primary)' }}
|
||||
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'}
|
||||
onMouseLeave={e=>e.currentTarget.style.background=''}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Nav */}
|
||||
<div style={{ display:'flex', alignItems:'center', gap:6 }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={()=>setSelectedDate(new Date())}>Today</button>
|
||||
<button className="btn-icon" onClick={()=>navDate(-1)}>‹</button>
|
||||
<button className="btn-icon" onClick={()=>navDate(1)}>›</button>
|
||||
{view !== 'schedule' && <span style={{ fontSize:14, fontWeight:600, minWidth:200 }}>{navLabel()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
||||
{/* View switcher */}
|
||||
<div style={{ display:'flex', gap:2, background:'var(--surface-variant)', borderRadius:'var(--radius)', padding:3 }}>
|
||||
{[['schedule','Schedule'],['day','Day'],['week','Week'],['month','Month']].map(([v,l])=>(
|
||||
<button key={v} onClick={()=>{setView(v);setRightPanel('calendar');}} style={{ padding:'4px 10px', borderRadius:5, border:'none', cursor:'pointer', fontSize:12, fontWeight:600, background: view===v ? 'var(--surface)' : 'transparent', color: view===v ? 'var(--text-primary)' : 'var(--text-tertiary)', boxShadow: view===v ? '0 1px 3px rgba(0,0,0,0.1)' : 'none' }}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn-icon" onClick={onClose}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ display:'flex', flex:1, overflow:'hidden' }}>
|
||||
{/* Left panel: mini calendar */}
|
||||
<div style={{ width:210, flexShrink:0, borderRight:'1px solid var(--border)', padding:16, overflowY:'auto' }}>
|
||||
<MiniCalendar selected={selectedDate} onChange={d=>{setSelectedDate(d);setRightPanel('calendar');}} eventDates={eventDates} />
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden' }}>
|
||||
{rightPanel === 'calendar' && view === 'schedule' && (
|
||||
<ScheduleView events={events} selectedDate={selectedDate} onSelectEvent={openEventDetail} />
|
||||
)}
|
||||
{rightPanel === 'calendar' && view === 'day' && (
|
||||
<DayView events={events} selectedDate={selectedDate} onSelectEvent={openEventDetail} />
|
||||
)}
|
||||
{rightPanel === 'calendar' && view === 'week' && (
|
||||
<WeekView events={events} selectedDate={selectedDate} onSelectEvent={openEventDetail} />
|
||||
)}
|
||||
{rightPanel === 'calendar' && view === 'month' && (
|
||||
<MonthView events={events} selectedDate={selectedDate} onSelectEvent={openEventDetail}
|
||||
onSelectDay={d=>{setSelectedDate(d);setView('schedule');}} />
|
||||
)}
|
||||
{rightPanel === 'eventForm' && (
|
||||
<div style={{ padding:24, overflowY:'auto', flex:1 }}>
|
||||
<EventForm event={editingEvent} userGroups={userGroups} eventTypes={eventTypes}
|
||||
selectedDate={selectedDate} isToolManager={isToolManager}
|
||||
onSave={handleEventSaved}
|
||||
onCancel={()=>{setRightPanel('calendar');setEditingEvent(null);}}
|
||||
onDelete={handleDeleteEvent} />
|
||||
</div>
|
||||
)}
|
||||
{rightPanel === 'eventTypes' && (
|
||||
<div style={{ padding:24, overflowY:'auto', flex:1 }}>
|
||||
<EventTypesPanel eventTypes={eventTypes} userGroups={userGroups} onUpdated={loadAll}
|
||||
onClose={()=>setRightPanel('calendar')} />
|
||||
</div>
|
||||
)}
|
||||
{rightPanel === 'bulkImport' && (
|
||||
<div style={{ padding:24, overflowY:'auto', flex:1 }}>
|
||||
<BulkImportPanel onImported={()=>{loadAll();setRightPanel('calendar');}} onCancel={()=>setRightPanel('calendar')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event detail popup */}
|
||||
{selectedEvent && (
|
||||
<EventDetailPopup
|
||||
event={selectedEvent}
|
||||
isToolManager={isToolManager}
|
||||
currentUserId={user?.id}
|
||||
onClose={()=>setSelectedEvent(null)}
|
||||
onEdit={()=>{ setEditingEvent(selectedEvent); setRightPanel('eventForm'); setSelectedEvent(null); }}
|
||||
onAvailabilityChange={()=>openEventDetail(selectedEvent)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import AboutModal from '../components/AboutModal.jsx';
|
||||
import HelpModal from '../components/HelpModal.jsx';
|
||||
import NavDrawer from '../components/NavDrawer.jsx';
|
||||
import GroupManagerModal from '../components/GroupManagerModal.jsx';
|
||||
import ScheduleManagerModal from '../components/ScheduleManagerModal.jsx';
|
||||
import './Chat.css';
|
||||
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
@@ -36,7 +37,7 @@ export default function Chat() {
|
||||
const [activeGroupId, setActiveGroupId] = useState(null);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unreadGroups, setUnreadGroups] = useState(new Map());
|
||||
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager'
|
||||
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager' | 'schedulemanager'
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'JAMA-Chat', teamToolManagers: [] });
|
||||
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
|
||||
@@ -371,6 +372,7 @@ export default function Chat() {
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onMessages={() => { setDrawerOpen(false); }}
|
||||
onGroupManager={() => { setDrawerOpen(false); setModal('groupmanager'); }}
|
||||
onScheduleManager={() => { setDrawerOpen(false); setModal('schedulemanager'); }}
|
||||
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
|
||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||
onUsers={() => { setDrawerOpen(false); setModal('users'); }}
|
||||
@@ -381,6 +383,12 @@ export default function Chat() {
|
||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||
{modal === 'groupmanager' && <GroupManagerModal onClose={() => setModal(null)} />}
|
||||
{modal === 'schedulemanager' && (
|
||||
<ScheduleManagerModal
|
||||
onClose={() => setModal(null)}
|
||||
isToolManager={user?.role === 'admin' || (features.teamToolManagers || []).some(gid => (features.userGroupMemberships || []).includes(gid))}
|
||||
/>
|
||||
)}
|
||||
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
||||
|
||||
@@ -102,7 +102,30 @@ export const api = {
|
||||
updateAppName: (name) => req('PATCH', '/settings/app-name', { name }),
|
||||
updateColors: (body) => req('PATCH', '/settings/colors', body),
|
||||
registerCode: (code) => req('POST', '/settings/register', { code }),
|
||||
updateTeamSettings: (body) => req('PATCH', '/settings/team', body), // body: { toolManagers: [groupId,...] }
|
||||
updateTeamSettings: (body) => req('PATCH', '/settings/team', body),
|
||||
|
||||
// Schedule Manager
|
||||
getEventTypes: () => req('GET', '/schedule/event-types'),
|
||||
createEventType: (body) => req('POST', '/schedule/event-types', body),
|
||||
updateEventType: (id, body) => req('PATCH', `/schedule/event-types/${id}`, body),
|
||||
deleteEventType: (id) => req('DELETE', `/schedule/event-types/${id}`),
|
||||
getEvents: (params = {}) => {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return req('GET', `/schedule${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getEvent: (id) => req('GET', `/schedule/${id}`),
|
||||
createEvent: (body) => req('POST', '/schedule', body),
|
||||
updateEvent: (id, body) => req('PATCH', `/schedule/${id}`, body),
|
||||
deleteEvent: (id) => req('DELETE', `/schedule/${id}`),
|
||||
setAvailability: (id, response) => req('PUT', `/schedule/${id}/availability`, { response }),
|
||||
deleteAvailability: (id) => req('DELETE', `/schedule/${id}/availability`),
|
||||
getPendingAvailability: () => req('GET', '/schedule/me/pending'),
|
||||
bulkAvailability: (responses) => req('POST', '/schedule/me/bulk-availability', { responses }),
|
||||
importPreview: (file) => {
|
||||
const fd = new FormData(); fd.append('file', file);
|
||||
return fetch('/api/schedule/import/preview', { method: 'POST', headers: { Authorization: 'Bearer ' + localStorage.getItem('jama-token') }, body: fd }).then(r => r.json());
|
||||
},
|
||||
importConfirm: (rows) => req('POST', '/schedule/import/confirm', { rows }),
|
||||
|
||||
// User groups (Group Manager)
|
||||
getMyUserGroups: () => req('GET', '/usergroups/me'),
|
||||
|
||||
Reference in New Issue
Block a user