From 25a26d9386cdb429abcd54ed4bf47b5a18190a18 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 30 Mar 2026 20:03:13 +0000 Subject: [PATCH 01/38] Implement Meridian event tasks and org task hub MVP Co-authored-by: James Liu --- backend/app.js | 2 + backend/routes/taskManagementRoutes.js | 397 +++++++++++++ backend/schemas/task.js | 205 +++++++ backend/services/getModelService.js | 2 + backend/services/taskService.js | 341 +++++++++++ frontend/src/pages/ClubDash/ClubDash.jsx | 7 + .../EventDashboard/EventDashboard.jsx | 12 + .../EventDashboard/EventDashboard.scss | 308 ++++++++++ .../EventDashboard/EventTasksTab.jsx | 543 ++++++++++++++++++ .../EventDashboard/EventTasksTab.scss | 258 +++++++++ .../src/pages/ClubDash/TasksHub/TasksHub.jsx | 410 +++++++++++++ .../src/pages/ClubDash/TasksHub/TasksHub.scss | 325 +++++++++++ frontend/src/pages/ClubDash/TasksHub/index.js | 1 + 13 files changed, 2811 insertions(+) create mode 100644 backend/routes/taskManagementRoutes.js create mode 100644 backend/schemas/task.js create mode 100644 backend/services/taskService.js create mode 100644 frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventTasksTab.jsx create mode 100644 frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventTasksTab.scss create mode 100644 frontend/src/pages/ClubDash/TasksHub/TasksHub.jsx create mode 100644 frontend/src/pages/ClubDash/TasksHub/TasksHub.scss create mode 100644 frontend/src/pages/ClubDash/TasksHub/index.js diff --git a/backend/app.js b/backend/app.js index 9f216c7b..7ea0fd6f 100644 --- a/backend/app.js +++ b/backend/app.js @@ -225,6 +225,7 @@ function createApp() { const orgManagementRoutes = require('./routes/orgManagementRoutes.js'); const orgInviteRoutes = require('./routes/orgInviteRoutes.js'); const orgMessageRoutes = require('./routes/orgMessageRoutes.js'); + const taskManagementRoutes = require('./routes/taskManagementRoutes.js'); const roomRoutes = require('./routes/roomRoutes.js'); const adminRoutes = require('./routes/adminRoutes.js'); const eventsRoutes = require('./events/index.js'); @@ -261,6 +262,7 @@ function createApp() { app.use('/org-invites', orgInviteRoutes); app.use('/org-messages', orgMessageRoutes); app.use('/org-event-management', orgEventManagementRoutes); + app.use('/org-event-management', taskManagementRoutes); app.use('/admin', roomRoutes); app.use(adminRoutes); app.use(formRoutes); diff --git a/backend/routes/taskManagementRoutes.js b/backend/routes/taskManagementRoutes.js new file mode 100644 index 00000000..56526602 --- /dev/null +++ b/backend/routes/taskManagementRoutes.js @@ -0,0 +1,397 @@ +const express = require('express'); +const router = express.Router(); +const { verifyToken } = require('../middlewares/verifyToken'); +const { requireEventManagement } = require('../middlewares/orgPermissions'); +const getModels = require('../services/getModelService'); +const { + asObjectId, + computeDueAtForTask, + computeEventReadiness, + listTasks, + recomputeDueDatesForEvent, + sortHubTasks +} = require('../services/taskService'); + +function toBoolean(value, defaultValue = false) { + if (value === undefined || value === null || value === '') return defaultValue; + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + const normalized = String(value).trim().toLowerCase(); + return ['1', 'true', 'yes', 'y', 'on'].includes(normalized); +} + +function applyTaskPatch(task, payload = {}) { + const scalarFields = [ + 'title', + 'description', + 'status', + 'priority', + 'source', + 'userConfirmed', + 'dueAt', + 'dueRule', + 'blockers', + 'integrationLinks', + 'tags', + 'metadata' + ]; + scalarFields.forEach((field) => { + if (payload[field] !== undefined) { + if ((field === 'title' || field === 'description') && typeof payload[field] === 'string') { + task[field] = payload[field].trim(); + return; + } + task[field] = payload[field]; + } + }); + if (payload.ownerUserId !== undefined) { + task.ownerUserId = payload.ownerUserId ? asObjectId(payload.ownerUserId) : null; + } + if (payload.watcherUserIds !== undefined) { + if (Array.isArray(payload.watcherUserIds)) { + task.watcherUserIds = payload.watcherUserIds + .map((id) => asObjectId(id)) + .filter(Boolean); + } else { + task.watcherUserIds = []; + } + } + if (payload.eventId !== undefined) { + task.eventId = payload.eventId ? asObjectId(payload.eventId) : null; + } + if (payload.isCritical !== undefined) { + task.isCritical = toBoolean(payload.isCritical, false); + } +} + +async function ensureOrgEventAccess(models, orgId, eventId) { + if (!eventId) return null; + const event = await models.Event.findOne({ + _id: asObjectId(eventId), + hostingType: 'Org', + isDeleted: false, + $or: [ + { hostingId: asObjectId(orgId) }, + { collaboratorOrgs: { $elemMatch: { orgId: asObjectId(orgId), status: 'active' } } } + ] + }).lean(); + return event || null; +} + +// Event-level task list +router.get('/:orgId/events/:eventId/tasks', verifyToken, requireEventManagement('orgId'), async (req, res) => { + const { orgId, eventId } = req.params; + const { status = 'all', ownerUserId, priority = 'all', search = '', sortBy = 'priority' } = req.query; + const models = getModels(req, 'Task', 'Event'); + + try { + const event = await ensureOrgEventAccess(models, orgId, eventId); + if (!event) { + return res.status(404).json({ success: false, message: 'Event not found' }); + } + + const tasks = await listTasks(models, orgId, { + eventId, + status, + ownerUserId, + priority, + search, + sortBy + }); + + return res.status(200).json({ + success: true, + data: { + event: { + _id: event._id, + name: event.name, + start_time: event.start_time, + end_time: event.end_time + }, + tasks + } + }); + } catch (error) { + console.error('Error listing event tasks:', error); + return res.status(500).json({ success: false, message: 'Error listing event tasks', error: error.message }); + } +}); + +// Create event task +router.post('/:orgId/events/:eventId/tasks', verifyToken, requireEventManagement('orgId'), async (req, res) => { + const { orgId, eventId } = req.params; + const payload = req.body || {}; + const models = getModels(req, 'Task', 'Event'); + + try { + const event = await ensureOrgEventAccess(models, orgId, eventId); + if (!event) { + return res.status(404).json({ success: false, message: 'Event not found' }); + } + if (!payload.title || !String(payload.title).trim()) { + return res.status(400).json({ success: false, message: 'Task title is required' }); + } + + const task = new models.Task({ + orgId: asObjectId(orgId), + eventId: asObjectId(eventId), + title: String(payload.title).trim(), + description: payload.description || '', + status: payload.status || 'todo', + priority: payload.priority || 'medium', + isCritical: toBoolean(payload.isCritical, false), + ownerUserId: payload.ownerUserId && asObjectId(payload.ownerUserId) ? asObjectId(payload.ownerUserId) : null, + source: payload.source || 'manual', + userConfirmed: payload.userConfirmed !== undefined ? toBoolean(payload.userConfirmed, false) : true, + dueRule: payload.dueRule || { anchorType: 'none' }, + blockers: Array.isArray(payload.blockers) ? payload.blockers : [], + integrationLinks: Array.isArray(payload.integrationLinks) ? payload.integrationLinks : [], + tags: Array.isArray(payload.tags) ? payload.tags : [] + }); + + task.dueAt = payload.dueAt ? new Date(payload.dueAt) : computeDueAtForTask(task, event, null); + await task.save(); + + return res.status(201).json({ + success: true, + message: 'Task created', + data: { task } + }); + } catch (error) { + console.error('Error creating event task:', error); + return res.status(500).json({ success: false, message: 'Error creating task', error: error.message }); + } +}); + +// Update event task +router.put('/:orgId/events/:eventId/tasks/:taskId', verifyToken, requireEventManagement('orgId'), async (req, res) => { + const { orgId, eventId, taskId } = req.params; + const payload = req.body || {}; + const models = getModels(req, 'Task', 'Event'); + + try { + const event = await ensureOrgEventAccess(models, orgId, eventId); + if (!event) { + return res.status(404).json({ success: false, message: 'Event not found' }); + } + const task = await models.Task.findOne({ + _id: asObjectId(taskId), + orgId: asObjectId(orgId), + eventId: asObjectId(eventId) + }); + if (!task) { + return res.status(404).json({ success: false, message: 'Task not found' }); + } + + applyTaskPatch(task, payload); + if (payload.dueRule !== undefined && payload.dueAt === undefined) { + task.dueAt = computeDueAtForTask(task, event, null); + } + await task.save(); + + return res.status(200).json({ + success: true, + message: 'Task updated', + data: { task } + }); + } catch (error) { + console.error('Error updating event task:', error); + return res.status(500).json({ success: false, message: 'Error updating task', error: error.message }); + } +}); + +// Delete event task +router.delete('/:orgId/events/:eventId/tasks/:taskId', verifyToken, requireEventManagement('orgId'), async (req, res) => { + const { orgId, eventId, taskId } = req.params; + const models = getModels(req, 'Task', 'Event'); + + try { + const event = await ensureOrgEventAccess(models, orgId, eventId); + if (!event) { + return res.status(404).json({ success: false, message: 'Event not found' }); + } + const deleted = await models.Task.findOneAndDelete({ + _id: asObjectId(taskId), + orgId: asObjectId(orgId), + eventId: asObjectId(eventId) + }); + if (!deleted) { + return res.status(404).json({ success: false, message: 'Task not found' }); + } + return res.status(200).json({ success: true, message: 'Task deleted' }); + } catch (error) { + console.error('Error deleting event task:', error); + return res.status(500).json({ success: false, message: 'Error deleting task', error: error.message }); + } +}); + +// Recompute event task due dates from relative rules +router.post('/:orgId/events/:eventId/tasks/recompute-due-dates', verifyToken, requireEventManagement('orgId'), async (req, res) => { + const { orgId, eventId } = req.params; + const models = getModels(req, 'Task', 'Event', 'ApprovalInstance'); + + try { + const event = await ensureOrgEventAccess(models, orgId, eventId); + if (!event) { + return res.status(404).json({ success: false, message: 'Event not found' }); + } + const updatedCount = await recomputeDueDatesForEvent(models, orgId, eventId); + return res.status(200).json({ + success: true, + message: 'Due dates recomputed', + data: { updatedCount } + }); + } catch (error) { + console.error('Error recomputing due dates:', error); + return res.status(500).json({ success: false, message: 'Error recomputing due dates', error: error.message }); + } +}); + +// Event readiness snapshot +router.get('/:orgId/events/:eventId/readiness', verifyToken, requireEventManagement('orgId'), async (req, res) => { + const { orgId, eventId } = req.params; + const models = getModels(req, 'Task', 'Event', 'ApprovalInstance', 'EventEquipment', 'EventJob'); + + try { + const event = await ensureOrgEventAccess(models, orgId, eventId); + if (!event) { + return res.status(404).json({ success: false, message: 'Event not found' }); + } + + const readiness = await computeEventReadiness(models, orgId, eventId); + return res.status(200).json({ + success: true, + data: readiness + }); + } catch (error) { + console.error('Error computing readiness:', error); + return res.status(500).json({ success: false, message: 'Error computing readiness', error: error.message }); + } +}); + +// Organization-level task hub +router.get('/:orgId/tasks/hub', verifyToken, requireEventManagement('orgId'), async (req, res) => { + const { + status = 'all', + ownerUserId, + priority = 'all', + eventId = 'all', + search = '', + onlyBlocked, + onlyOverdue, + sortBy = 'urgency' + } = req.query; + const { orgId } = req.params; + const models = getModels(req, 'Task', 'Event'); + + try { + const tasks = await listTasks(models, orgId, { + eventId: eventId === 'all' ? undefined : eventId, + status, + ownerUserId, + priority, + search, + onlyBlocked: toBoolean(onlyBlocked, false), + onlyOverdue: toBoolean(onlyOverdue, false) + }); + + const sorted = sortHubTasks(tasks, sortBy); + const summary = { + total: sorted.length, + overdue: sorted.filter((task) => task.overdue).length, + blocked: sorted.filter((task) => task.effectiveStatus === 'blocked').length, + highPriority: sorted.filter((task) => ['high', 'critical'].includes(task.priority)).length + }; + + return res.status(200).json({ + success: true, + data: { + summary, + tasks: sorted + } + }); + } catch (error) { + console.error('Error loading task hub:', error); + return res.status(500).json({ success: false, message: 'Error loading task hub', error: error.message }); + } +}); + +// Create organization-level operational task (non-event task) +router.post('/:orgId/tasks/hub', verifyToken, requireEventManagement('orgId'), async (req, res) => { + const { orgId } = req.params; + const payload = req.body || {}; + const models = getModels(req, 'Task'); + + try { + if (!payload.title || !String(payload.title).trim()) { + return res.status(400).json({ success: false, message: 'Task title is required' }); + } + + const ownerObjectId = payload.ownerUserId && asObjectId(payload.ownerUserId); + const eventObjectId = payload.eventId && asObjectId(payload.eventId); + + const task = new models.Task({ + orgId: asObjectId(orgId), + eventId: eventObjectId || null, + title: String(payload.title).trim(), + description: payload.description || '', + status: payload.status || 'todo', + priority: payload.priority || 'medium', + isCritical: toBoolean(payload.isCritical, false), + ownerUserId: ownerObjectId || null, + source: payload.source || 'manual', + userConfirmed: payload.userConfirmed !== undefined ? toBoolean(payload.userConfirmed, false) : true, + dueRule: payload.dueRule || { anchorType: 'none' }, + dueAt: payload.dueAt ? new Date(payload.dueAt) : null, + blockers: Array.isArray(payload.blockers) ? payload.blockers : [], + integrationLinks: Array.isArray(payload.integrationLinks) ? payload.integrationLinks : [], + tags: Array.isArray(payload.tags) ? payload.tags : [] + }); + + await task.save(); + return res.status(201).json({ + success: true, + message: 'Task created', + data: { task } + }); + } catch (error) { + console.error('Error creating hub task:', error); + return res.status(500).json({ success: false, message: 'Error creating task', error: error.message }); + } +}); + +// Update organization-level task by id +router.put('/:orgId/tasks/hub/:taskId', verifyToken, requireEventManagement('orgId'), async (req, res) => { + const { orgId, taskId } = req.params; + const payload = req.body || {}; + const models = getModels(req, 'Task', 'Event'); + + try { + const task = await models.Task.findOne({ + _id: asObjectId(taskId), + orgId: asObjectId(orgId) + }); + if (!task) { + return res.status(404).json({ success: false, message: 'Task not found' }); + } + + applyTaskPatch(task, payload); + + if (payload.dueRule !== undefined && payload.dueAt === undefined && task.eventId) { + const event = await models.Event.findById(task.eventId).lean(); + task.dueAt = computeDueAtForTask(task, event, null); + } + + await task.save(); + return res.status(200).json({ + success: true, + message: 'Task updated', + data: { task } + }); + } catch (error) { + console.error('Error updating hub task:', error); + return res.status(500).json({ success: false, message: 'Error updating task', error: error.message }); + } +}); + +module.exports = router; diff --git a/backend/schemas/task.js b/backend/schemas/task.js new file mode 100644 index 00000000..f9271780 --- /dev/null +++ b/backend/schemas/task.js @@ -0,0 +1,205 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const taskDueRuleSchema = new Schema({ + anchorType: { + type: String, + enum: ['event_start', 'event_end', 'approval_granted', 'absolute', 'none'], + default: 'none' + }, + offsetValue: { + type: Number, + default: 0 + }, + offsetUnit: { + type: String, + enum: ['minutes', 'hours', 'days', 'weeks'], + default: 'days' + }, + direction: { + type: String, + enum: ['before', 'after'], + default: 'before' + }, + absoluteDate: { + type: Date, + default: null + } +}, { _id: false }); + +const taskBlockerSchema = new Schema({ + type: { + type: String, + enum: ['approval', 'booking', 'task', 'dependency', 'manual'], + default: 'manual' + }, + referenceId: { + type: String, + default: null + }, + label: { + type: String, + trim: true, + default: '' + }, + resolved: { + type: Boolean, + default: false + } +}, { _id: false }); + +const taskIntegrationSchema = new Schema({ + type: { + type: String, + enum: ['approval_instance', 'room_booking', 'registration_goal', 'agenda_item', 'event_job'], + required: true + }, + referenceId: { + type: String, + required: true + }, + status: { + type: String, + default: 'pending' + } +}, { _id: false }); + +const TaskSchema = new Schema({ + orgId: { + type: Schema.Types.ObjectId, + ref: 'Org', + required: true, + index: true + }, + eventId: { + type: Schema.Types.ObjectId, + ref: 'Event', + required: false, + default: null, + index: true + }, + title: { + type: String, + required: true, + trim: true, + maxlength: 180 + }, + description: { + type: String, + trim: true, + default: '' + }, + status: { + type: String, + enum: ['todo', 'in_progress', 'blocked', 'done', 'cancelled'], + default: 'todo', + index: true + }, + priority: { + type: String, + enum: ['low', 'medium', 'high', 'critical'], + default: 'medium', + index: true + }, + isCritical: { + type: Boolean, + default: false + }, + ownerUserId: { + type: Schema.Types.ObjectId, + ref: 'User', + default: null, + index: true + }, + watcherUserIds: [{ + type: Schema.Types.ObjectId, + ref: 'User' + }], + source: { + type: String, + enum: ['manual', 'template_suggestion', 'template_applied', 'integration_generated'], + default: 'manual' + }, + userConfirmed: { + type: Boolean, + default: false + }, + templateSource: { + templateId: { + type: Schema.Types.ObjectId, + default: null + }, + templateTaskKey: { + type: String, + default: null + } + }, + dueRule: { + type: taskDueRuleSchema, + default: () => ({ anchorType: 'none' }) + }, + dueAt: { + type: Date, + default: null, + index: true + }, + blockers: { + type: [taskBlockerSchema], + default: [] + }, + integrationLinks: { + type: [taskIntegrationSchema], + default: [] + }, + tags: { + type: [String], + default: [] + }, + readinessContribution: { + weight: { + type: Number, + default: 1, + min: 0 + }, + blocked: { + type: Boolean, + default: false + } + }, + completedAt: { + type: Date, + default: null + }, + cancelledAt: { + type: Date, + default: null + }, + metadata: { + type: Schema.Types.Mixed, + default: {} + } +}, { + timestamps: true +}); + +TaskSchema.index({ orgId: 1, eventId: 1, status: 1 }); +TaskSchema.index({ orgId: 1, dueAt: 1 }); +TaskSchema.index({ orgId: 1, ownerUserId: 1, status: 1 }); + +TaskSchema.pre('save', function syncCompletionTimestamps(next) { + if (this.status === 'done' && !this.completedAt) { + this.completedAt = new Date(); + } + if (this.status !== 'done' && this.completedAt) { + this.completedAt = null; + } + if (this.status === 'cancelled' && !this.cancelledAt) { + this.cancelledAt = new Date(); + } + if (this.status !== 'cancelled' && this.cancelledAt) { + this.cancelledAt = null; + } + next(); +}); + +module.exports = TaskSchema; diff --git a/backend/services/getModelService.js b/backend/services/getModelService.js index eb99d42d..4bd9b1d9 100644 --- a/backend/services/getModelService.js +++ b/backend/services/getModelService.js @@ -59,6 +59,7 @@ const eventEquipmentSchema = require('../schemas/EventEquipment'); const orgEquipmentSchema = require('../schemas/OrgEquipment'); const analyticsEventSchema = require('../events/schemas/analyticsEvent'); const eventQRSchema = require('../events/schemas/eventQR'); +const taskSchema = require('../schemas/task'); @@ -124,6 +125,7 @@ const getModels = (req, ...names) => { OrgEquipment: req.db.model('OrgEquipment', orgEquipmentSchema, 'orgEquipment'), AnalyticsEvent: req.db.model('AnalyticsEvent', analyticsEventSchema, 'analytics_events'), EventQR: req.db.model('EventQR', eventQRSchema, 'event_qrs'), + Task: req.db.model('Task', taskSchema, 'tasks'), ResourcesConfig: req.db.model('ResourcesConfig', resourcesConfigSchema, 'resourcesConfigs'), ShuttleConfig: req.db.model('ShuttleConfig', shuttleConfigSchema, 'shuttleConfigs'), NoticeConfig: req.db.model('NoticeConfig', noticeConfigSchema, 'noticeConfigs'), diff --git a/backend/services/taskService.js b/backend/services/taskService.js new file mode 100644 index 00000000..008664ca --- /dev/null +++ b/backend/services/taskService.js @@ -0,0 +1,341 @@ +const mongoose = require('mongoose'); + +const PRIORITY_WEIGHTS = { + low: 1, + medium: 2, + high: 3, + critical: 4 +}; + +const STATUS_SORT = { + blocked: 0, + in_progress: 1, + todo: 2, + done: 3, + cancelled: 4 +}; + +function asObjectId(id) { + if (!id) return null; + if (id instanceof mongoose.Types.ObjectId) return id; + if (mongoose.Types.ObjectId.isValid(id)) return new mongoose.Types.ObjectId(id); + return null; +} + +function addOffset(baseDate, offsetValue, offsetUnit, direction) { + const date = new Date(baseDate); + const sign = direction === 'after' ? 1 : -1; + const amount = Number(offsetValue || 0) * sign; + switch (offsetUnit) { + case 'minutes': + date.setMinutes(date.getMinutes() + amount); + break; + case 'hours': + date.setHours(date.getHours() + amount); + break; + case 'weeks': + date.setDate(date.getDate() + (amount * 7)); + break; + case 'days': + default: + date.setDate(date.getDate() + amount); + break; + } + return date; +} + +function computeDueAtForTask(task, event, approvalAnchorDate = null) { + const dueRule = task?.dueRule || {}; + if (!dueRule || dueRule.anchorType === 'none') return null; + if (dueRule.anchorType === 'absolute') { + return dueRule.absoluteDate ? new Date(dueRule.absoluteDate) : null; + } + + let anchorDate = null; + if (dueRule.anchorType === 'event_start') { + anchorDate = event?.start_time ? new Date(event.start_time) : null; + } else if (dueRule.anchorType === 'event_end') { + anchorDate = event?.end_time ? new Date(event.end_time) : null; + } else if (dueRule.anchorType === 'approval_granted') { + anchorDate = approvalAnchorDate ? new Date(approvalAnchorDate) : null; + } + + if (!anchorDate) return null; + return addOffset(anchorDate, dueRule.offsetValue, dueRule.offsetUnit, dueRule.direction); +} + +async function resolveApprovalAnchorDate(models, eventId) { + if (!eventId || !models?.ApprovalInstance) return null; + try { + const approval = await models.ApprovalInstance.findOne({ + eventId: asObjectId(eventId), + status: 'approved' + }).sort({ updatedAt: -1 }).lean(); + return approval?.updatedAt || null; + } catch (_error) { + return null; + } +} + +function buildTaskSort(sortBy = 'priority') { + if (sortBy === 'dueAt') return { dueAt: 1, priority: -1, createdAt: -1 }; + if (sortBy === 'createdAt') return { createdAt: -1 }; + if (sortBy === 'status') return { status: 1, priority: -1, dueAt: 1, createdAt: -1 }; + return { priority: -1, dueAt: 1, createdAt: -1 }; +} + +function toTaskDto(task) { + const plain = task.toObject ? task.toObject() : task; + const blockedByUnresolved = (plain.blockers || []).some((blocker) => !blocker.resolved); + const effectiveStatus = blockedByUnresolved && plain.status !== 'done' && plain.status !== 'cancelled' + ? 'blocked' + : plain.status; + + return { + ...plain, + effectiveStatus, + priorityWeight: PRIORITY_WEIGHTS[plain.priority] || PRIORITY_WEIGHTS.medium, + overdue: Boolean(plain.dueAt && new Date(plain.dueAt) < new Date() && !['done', 'cancelled'].includes(plain.status)) + }; +} + +function computeUrgencyScore(taskDto) { + const now = Date.now(); + const dueAtMs = taskDto.dueAt ? new Date(taskDto.dueAt).getTime() : null; + const priority = PRIORITY_WEIGHTS[taskDto.priority] || PRIORITY_WEIGHTS.medium; + const blockedPenalty = taskDto.effectiveStatus === 'blocked' ? 35 : 0; + const criticalBoost = taskDto.isCritical ? 25 : 0; + + let dueScore = 0; + if (dueAtMs) { + const daysUntilDue = (dueAtMs - now) / (1000 * 60 * 60 * 24); + if (daysUntilDue < 0) { + dueScore = 50 + Math.min(30, Math.abs(daysUntilDue) * 5); + } else if (daysUntilDue <= 1) { + dueScore = 35; + } else if (daysUntilDue <= 3) { + dueScore = 25; + } else if (daysUntilDue <= 7) { + dueScore = 15; + } else { + dueScore = 8; + } + } + + return (priority * 12) + dueScore + criticalBoost + blockedPenalty; +} + +function scoreBand(score) { + if (score >= 85) return 'ready'; + if (score >= 65) return 'on_track'; + if (score >= 40) return 'at_risk'; + return 'not_ready'; +} + +async function computeEventReadiness(models, orgId, eventId) { + const eventObjectId = asObjectId(eventId); + if (!eventObjectId) return null; + + const [event, tasks, approvalInstances, equipment, roles] = await Promise.all([ + models.Event.findOne({ _id: eventObjectId, hostingType: 'Org', isDeleted: false }).lean(), + models.Task.find({ orgId: asObjectId(orgId), eventId: eventObjectId }).lean(), + models.ApprovalInstance + ? models.ApprovalInstance.find({ eventId: eventObjectId }).lean() + : Promise.resolve([]), + models.EventEquipment + ? models.EventEquipment.findOne({ eventId: eventObjectId }).lean() + : Promise.resolve(null), + models.EventJob + ? models.EventJob.find({ eventId: eventObjectId }).lean() + : Promise.resolve([]) + ]); + + if (!event) return null; + + const actionableTasks = tasks.filter((task) => !['cancelled'].includes(task.status)); + const doneTasks = actionableTasks.filter((task) => task.status === 'done'); + const taskCompletion = actionableTasks.length > 0 + ? (doneTasks.length / actionableTasks.length) + : 0; + + const criticalIncomplete = actionableTasks.filter( + (task) => task.isCritical && !['done', 'cancelled'].includes(task.status) + ); + const blockedCritical = criticalIncomplete.filter((task) => + (task.blockers || []).some((blocker) => !blocker.resolved) + ); + + const approvalsRequired = approvalInstances.length > 0; + const approvalsApproved = approvalInstances.filter((instance) => instance.status === 'approved').length; + const approvalsScore = approvalsRequired + ? approvalsApproved / approvalInstances.length + : 1; + + const hasEquipment = Boolean(equipment?.items?.length); + const roleCount = roles.length; + const roleCoverage = roles.length === 0 + ? 1 + : roles.filter((role) => (role.assignments || []).length >= (role.requiredCount || 1)).length / roles.length; + const logisticsScoreRaw = (roleCoverage * 0.7) + ((hasEquipment || roleCount === 0) ? 0.3 : 0); + + const registrationGoal = Number(event.expectedAttendance || 0); + const registrations = Number(event.registrationCount || event.attendees?.length || 0); + const engagementScore = registrationGoal > 0 ? Math.min(1, registrations / registrationGoal) : 1; + + const weightedScore = ( + (taskCompletion * 0.40) + + (approvalsScore * 0.25) + + (logisticsScoreRaw * 0.20) + + (engagementScore * 0.15) + ) * 100; + + const hardBlockers = []; + if (blockedCritical.length > 0) { + hardBlockers.push({ + type: 'critical_blocked_tasks', + label: `${blockedCritical.length} critical task(s) blocked`, + ownerUserIds: blockedCritical.map((task) => task.ownerUserId).filter(Boolean) + }); + } + const pendingApprovals = approvalInstances.filter((instance) => instance.status !== 'approved'); + if (pendingApprovals.length > 0) { + hardBlockers.push({ + type: 'approvals_pending', + label: `${pendingApprovals.length} approval item(s) unresolved` + }); + } + if (new Date(event.start_time).getTime() - Date.now() <= (72 * 60 * 60 * 1000) && criticalIncomplete.length > 0) { + hardBlockers.push({ + type: 'critical_near_deadline', + label: 'Critical tasks unresolved with less than 72h to event start' + }); + } + + const cappedScore = hardBlockers.length > 0 ? Math.min(weightedScore, 64.9) : weightedScore; + + return { + score: Math.round(cappedScore * 10) / 10, + band: scoreBand(cappedScore), + dimensions: { + taskCompletion: Math.round(taskCompletion * 100), + approvals: Math.round(approvalsScore * 100), + logistics: Math.round(logisticsScoreRaw * 100), + engagementReadiness: Math.round(engagementScore * 100) + }, + blockers: hardBlockers, + missing: [ + ...criticalIncomplete.map((task) => ({ + type: 'task', + taskId: task._id, + label: task.title, + ownerUserId: task.ownerUserId || null + })) + ] + }; +} + +async function recomputeDueDatesForEvent(models, orgId, eventId) { + const eventObjectId = asObjectId(eventId); + const orgObjectId = asObjectId(orgId); + if (!eventObjectId || !orgObjectId) return 0; + + const [event, approvalAnchorDate, tasks] = await Promise.all([ + models.Event.findOne({ _id: eventObjectId, hostingType: 'Org', isDeleted: false }).lean(), + resolveApprovalAnchorDate(models, eventObjectId), + models.Task.find({ orgId: orgObjectId, eventId: eventObjectId }) + ]); + + if (!event || tasks.length === 0) return 0; + + let updates = 0; + await Promise.all(tasks.map(async (task) => { + const nextDueAt = computeDueAtForTask(task, event, approvalAnchorDate); + const hasChanged = String(task.dueAt || '') !== String(nextDueAt || ''); + if (hasChanged) { + task.dueAt = nextDueAt; + await task.save(); + updates += 1; + } + })); + return updates; +} + +async function listTasks(models, orgId, options = {}) { + const orgObjectId = asObjectId(orgId); + if (!orgObjectId) return []; + + const query = { orgId: orgObjectId }; + if (options.eventId === null || options.eventId === 'null') { + query.eventId = null; + } else if (options.eventId) { + query.eventId = asObjectId(options.eventId); + } + if (options.status && options.status !== 'all') { + query.status = options.status; + } + if (options.ownerUserId && options.ownerUserId !== 'unassigned') { + query.ownerUserId = asObjectId(options.ownerUserId); + } else if (options.ownerUserId === 'unassigned') { + query.ownerUserId = null; + } + if (options.priority && options.priority !== 'all') { + query.priority = options.priority; + } + if (options.search) { + query.$or = [ + { title: { $regex: options.search, $options: 'i' } }, + { description: { $regex: options.search, $options: 'i' } } + ]; + } + + const sort = buildTaskSort(options.sortBy); + const tasks = await models.Task.find(query) + .sort(sort) + .populate('ownerUserId', 'name username picture') + .populate('eventId', 'name start_time end_time') + .lean(); + + const taskDtos = tasks.map(toTaskDto).map((task) => ({ + ...task, + urgencyScore: computeUrgencyScore(task) + })); + + if (options.onlyOverdue) { + return taskDtos.filter((task) => task.overdue); + } + if (options.onlyBlocked) { + return taskDtos.filter((task) => task.effectiveStatus === 'blocked'); + } + return taskDtos; +} + +function sortHubTasks(tasks, sortBy = 'urgency') { + const cloned = [...tasks]; + if (sortBy === 'dueAt') { + return cloned.sort((a, b) => { + const aDue = a.dueAt ? new Date(a.dueAt).getTime() : Number.MAX_SAFE_INTEGER; + const bDue = b.dueAt ? new Date(b.dueAt).getTime() : Number.MAX_SAFE_INTEGER; + return aDue - bDue; + }); + } + if (sortBy === 'priority') { + return cloned.sort((a, b) => { + const byPriority = (b.priorityWeight || 0) - (a.priorityWeight || 0); + if (byPriority !== 0) return byPriority; + const byStatus = (STATUS_SORT[a.effectiveStatus] ?? 10) - (STATUS_SORT[b.effectiveStatus] ?? 10); + if (byStatus !== 0) return byStatus; + return (new Date(a.createdAt).getTime()) - (new Date(b.createdAt).getTime()); + }); + } + return cloned.sort((a, b) => (b.urgencyScore || 0) - (a.urgencyScore || 0)); +} + +module.exports = { + PRIORITY_WEIGHTS, + asObjectId, + computeDueAtForTask, + computeEventReadiness, + recomputeDueDatesForEvent, + listTasks, + sortHubTasks +}; diff --git a/frontend/src/pages/ClubDash/ClubDash.jsx b/frontend/src/pages/ClubDash/ClubDash.jsx index 433877b5..f204b8a7 100644 --- a/frontend/src/pages/ClubDash/ClubDash.jsx +++ b/frontend/src/pages/ClubDash/ClubDash.jsx @@ -20,6 +20,7 @@ import apiRequest from '../../utils/postRequest'; import { useLocation } from 'react-router-dom'; import EventsPanel from './EventsPanel/EventsPanel'; import EventsManagement from './EventsManagement/EventsManagement'; +import TasksHub from './TasksHub/TasksHub'; import ClubForms from './ClubForms/ClubForms'; import ClubAnnouncements from './ClubAnnouncements/ClubAnnouncements'; import OrgMessageFeed from '../../components/OrgMessages/OrgMessageFeed'; @@ -250,6 +251,12 @@ function ClubDash(){ key: 'events', element: }, + { + label: 'Tasks', + icon: 'mdi:check-all', + key: 'tasks', + element: + }, { label: 'Announcements', icon: 'mdi:message-text', diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.jsx index e1855cf6..d1bfa945 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.jsx +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.jsx @@ -21,6 +21,7 @@ import EventQRTab from './EventQRTab/EventQRTab'; import RegistrationsTab from './RegistrationsTab/RegistrationsTab'; import CommunicationsTab from './CommunicationsTab/CommunicationsTab'; import ComingSoon from './ComingSoon'; +import EventTasksTab from './EventTasksTab'; // Temporarily disabled - EquipmentManager functionality commented out // import EquipmentManager from './EventEquipment/EquipmentManager'; import './EventDashboard.scss'; @@ -262,6 +263,17 @@ function EventDashboard({ event, orgId, onClose, className = '' }) { onRefresh={handleRefresh} /> }, + { + id: 'tasks', + label: 'Tasks', + icon: 'mdi:check-circle-outline', + description: 'Plan and execute event tasks', + content: + }, { id: 'analytics', label: 'Analytics', diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.scss index 8adaf51f..289a2a13 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.scss +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.scss @@ -1131,6 +1131,314 @@ } } +.event-tasks-tab { + padding: 1.5rem 0; + + .event-tasks-tab__header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + margin-bottom: 1rem; + + h3 { + margin: 0; + font-size: 1.25rem; + } + + p { + margin: 0.25rem 0 0; + color: var(--light-text); + font-size: 0.9rem; + } + } + + .event-tasks-tab__summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.75rem; + margin-bottom: 1rem; + } + + .event-tasks-tab__summary-card { + border: 1px solid var(--lighterborder); + border-radius: 10px; + background: var(--lightbackground); + padding: 0.75rem; + + &.event-tasks-tab__summary-card--overdue { + border-color: rgba(220, 53, 69, 0.35); + } + + .event-tasks-tab__summary-label { + font-size: 0.8rem; + color: var(--light-text); + display: block; + } + + .event-tasks-tab__summary-value { + font-size: 1.2rem; + font-weight: 700; + margin-top: 0.15rem; + display: block; + } + } + + .event-tasks-tab__readiness { + border: 1px solid var(--lighterborder); + border-radius: 12px; + background: var(--lightbackground); + padding: 0.85rem 1rem; + margin-bottom: 1rem; + display: flex; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + align-items: center; + + .event-tasks-tab__readiness-left { + display: flex; + flex-direction: column; + gap: 0.2rem; + } + + .event-tasks-tab__readiness-title { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-weight: 700; + } + + .event-tasks-tab__readiness-meta { + color: var(--light-text); + font-size: 0.85rem; + } + + .event-tasks-tab__readiness-band { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.5rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: capitalize; + margin-left: 0.25rem; + background: rgba(108, 117, 125, 0.12); + color: var(--light-text); + + &.event-tasks-tab__readiness-band--ready { + background: rgba(77, 170, 87, 0.15); + color: #3b8e47; + } + + &.event-tasks-tab__readiness-band--on_track { + background: rgba(74, 127, 255, 0.12); + color: #345ec2; + } + + &.event-tasks-tab__readiness-band--at_risk, + &.event-tasks-tab__readiness-band--not_ready { + background: rgba(220, 53, 69, 0.12); + color: #b43f4e; + } + } + } + + .event-tasks-tab__readiness-grid { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + } + + .event-tasks-tab__dim { + background: var(--background); + border: 1px solid var(--lighterborder); + border-radius: 8px; + padding: 0.4rem 0.55rem; + font-size: 0.8rem; + display: inline-flex; + gap: 0.4rem; + align-items: center; + } + + .event-tasks-tab__filters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.6rem; + margin-bottom: 1rem; + + input, + select { + width: 100%; + border: 1px solid var(--lighterborder); + border-radius: 8px; + padding: 0.55rem 0.65rem; + background: var(--background); + color: var(--text); + font-size: 0.85rem; + } + } + + .event-tasks-tab__create { + border: 1px solid var(--lighterborder); + border-radius: 12px; + background: var(--lightbackground); + padding: 0.9rem; + margin-bottom: 1rem; + + h4 { + margin: 0 0 0.55rem; + font-size: 1rem; + } + } + + .event-tasks-tab__create-row { + display: grid; + grid-template-columns: 2fr 2fr 1fr 1fr; + gap: 0.6rem; + margin-bottom: 0.6rem; + + @media (max-width: 980px) { + grid-template-columns: 1fr 1fr; + } + } + + .event-tasks-tab__create-row input, + .event-tasks-tab__create-row select { + border: 1px solid var(--lighterborder); + border-radius: 8px; + padding: 0.55rem 0.65rem; + background: var(--background); + color: var(--text); + font-size: 0.85rem; + } + + .event-tasks-tab__create-row--due { + display: grid; + grid-template-columns: 1.3fr 1fr 1fr 1fr 1fr; + gap: 0.6rem; + margin-bottom: 0.6rem; + + @media (max-width: 980px) { + grid-template-columns: 1fr 1fr; + } + } + + .event-tasks-tab__create-actions { + display: flex; + justify-content: flex-end; + } + + .event-tasks-tab__task-list { + display: flex; + flex-direction: column; + gap: 0.65rem; + } + + .event-tasks-tab__task-item { + border: 1px solid var(--lighterborder); + border-radius: 10px; + background: var(--lightbackground); + padding: 0.75rem; + display: flex; + justify-content: space-between; + gap: 0.75rem; + align-items: center; + + &.event-tasks-tab__task-item--done { + opacity: 0.82; + } + } + + .event-tasks-tab__task-main { + min-width: 0; + } + + .event-tasks-tab__task-title-row { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; + + h4 { + margin: 0; + font-size: 0.98rem; + } + } + + .event-tasks-tab__critical-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.7rem; + border-radius: 999px; + padding: 0.16rem 0.45rem; + background: rgba(220, 53, 69, 0.12); + color: #be3d4d; + } + + .event-tasks-tab__task-description { + margin: 0.35rem 0 0; + color: var(--light-text); + font-size: 0.85rem; + } + + .event-tasks-tab__task-meta { + margin-top: 0.45rem; + display: flex; + gap: 0.45rem; + flex-wrap: wrap; + } + + .event-tasks-tab__chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.73rem; + border-radius: 999px; + padding: 0.17rem 0.48rem; + background: var(--background); + border: 1px solid var(--lighterborder); + color: var(--light-text); + } + + .event-tasks-tab__task-actions { + display: flex; + gap: 0.45rem; + align-items: center; + flex-wrap: wrap; + } + + .event-tasks-tab__empty { + border: 1px dashed var(--lighterborder); + border-radius: 10px; + padding: 1rem; + color: var(--light-text); + background: var(--lightbackground); + } + + .event-tasks-tab__loading, + .event-tasks-tab__error { + border: 1px solid var(--lighterborder); + border-radius: 10px; + padding: 0.9rem; + background: var(--lightbackground); + color: var(--light-text); + display: flex; + align-items: center; + gap: 0.55rem; + } +} + +.btn-tertiary { + border: 1px solid var(--lighterborder); + background: var(--background); + color: var(--text); + padding: 0.5rem 0.8rem; + border-radius: 8px; + cursor: pointer; +} + // RSVP Growth Chart Styles .rsvp-growth-chart { width: 100%; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventTasksTab.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventTasksTab.jsx new file mode 100644 index 00000000..9ea33177 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventTasksTab.jsx @@ -0,0 +1,543 @@ +import React, { useMemo, useState } from 'react'; +import { Icon } from '@iconify-icon/react'; +import { useFetch } from '../../../../../hooks/useFetch'; +import apiRequest from '../../../../../utils/postRequest'; +import { useNotification } from '../../../../../NotificationContext'; +import './EventTasksTab.scss'; + +const DEFAULT_TASK_FORM = { + title: '', + description: '', + priority: 'medium', + isCritical: false, + status: 'todo', + dueMode: 'none', + dueAt: '', + dueRule: { + anchorType: 'event_start', + offsetValue: 14, + offsetUnit: 'days', + direction: 'before' + } +}; + +function StatusPill({ status }) { + return {status.replace('_', ' ')}; +} + +function PriorityPill({ priority }) { + return {priority}; +} + +function EventTasksTab({ event, orgId, onRefresh }) { + const { addNotification } = useNotification(); + const [newTask, setNewTask] = useState(DEFAULT_TASK_FORM); + const [submitting, setSubmitting] = useState(false); + const [actioningTaskId, setActioningTaskId] = useState(null); + const [viewMode, setViewMode] = useState('list'); + const [statusFilter, setStatusFilter] = useState('all'); + const [priorityFilter, setPriorityFilter] = useState('all'); + const [search, setSearch] = useState(''); + + const query = useMemo(() => { + const params = new URLSearchParams(); + params.set('status', statusFilter); + params.set('priority', priorityFilter); + if (search.trim()) params.set('search', search.trim()); + return params.toString(); + }, [statusFilter, priorityFilter, search]); + + const { data, loading, error, refetch } = useFetch( + event?._id && orgId + ? `/org-event-management/${orgId}/events/${event._id}/tasks?${query}` + : null + ); + const readinessRequest = useFetch( + event?._id && orgId ? `/org-event-management/${orgId}/events/${event._id}/readiness` : null + ); + + const tasks = useMemo(() => data?.data?.tasks || [], [data]); + const readiness = readinessRequest.data?.data || null; + + const groupedByStatus = useMemo(() => { + const groups = { + todo: [], + in_progress: [], + blocked: [], + done: [] + }; + tasks.forEach((task) => { + const key = task.effectiveStatus || task.status || 'todo'; + if (!groups[key]) groups[key] = []; + groups[key].push(task); + }); + return groups; + }, [tasks]); + + const metrics = useMemo(() => { + const total = tasks.length; + const done = tasks.filter((task) => task.status === 'done').length; + const blocked = tasks.filter((task) => task.effectiveStatus === 'blocked').length; + const overdue = tasks.filter((task) => task.overdue).length; + return { + total, + done, + blocked, + overdue, + completion: total > 0 ? Math.round((done / total) * 100) : 0 + }; + }, [tasks]); + + const handleCreateTask = async (e) => { + e.preventDefault(); + if (!newTask.title.trim()) { + addNotification({ + title: 'Task title required', + message: 'Please enter a task title before adding.', + type: 'error' + }); + return; + } + + setSubmitting(true); + try { + const payload = { + title: newTask.title.trim(), + description: newTask.description.trim(), + priority: newTask.priority, + status: newTask.status, + isCritical: newTask.isCritical, + userConfirmed: true + }; + if (newTask.dueMode === 'absolute' && newTask.dueAt) { + payload.dueAt = new Date(newTask.dueAt).toISOString(); + } + if (newTask.dueMode === 'relative') { + payload.dueRule = { + anchorType: newTask.dueRule.anchorType, + offsetValue: Number(newTask.dueRule.offsetValue) || 0, + offsetUnit: newTask.dueRule.offsetUnit, + direction: newTask.dueRule.direction + }; + } + + const response = await apiRequest( + `/org-event-management/${orgId}/events/${event._id}/tasks`, + payload, + { method: 'POST' } + ); + + if (!response?.success) { + throw new Error(response?.message || response?.error || 'Unable to create task'); + } + + addNotification({ + title: 'Task created', + message: 'Task added to this event execution plan.', + type: 'success' + }); + setNewTask(DEFAULT_TASK_FORM); + refetch(); + onRefresh?.(); + readinessRequest.refetch(); + } catch (createError) { + addNotification({ + title: 'Failed to create task', + message: createError.message || 'Please try again.', + type: 'error' + }); + } finally { + setSubmitting(false); + } + }; + + const handleQuickStatusChange = async (taskId, nextStatus) => { + setActioningTaskId(taskId); + try { + const response = await apiRequest( + `/org-event-management/${orgId}/events/${event._id}/tasks/${taskId}`, + { status: nextStatus }, + { method: 'PUT' } + ); + if (!response?.success) { + throw new Error(response?.message || response?.error || 'Unable to update task'); + } + refetch(); + onRefresh?.(); + } catch (updateError) { + addNotification({ + title: 'Task update failed', + message: updateError.message || 'Please try again.', + type: 'error' + }); + } finally { + setActioningTaskId(null); + } + }; + + const handleRecomputeDueDates = async () => { + setActioningTaskId('recompute'); + try { + const response = await apiRequest( + `/org-event-management/${orgId}/events/${event._id}/tasks/recompute-due-dates`, + {}, + { method: 'POST' } + ); + if (!response?.success) { + throw new Error(response?.message || response?.error || 'Unable to recompute due dates'); + } + addNotification({ + title: 'Due dates updated', + message: `Recomputed ${response?.data?.updatedCount || 0} task deadline(s).`, + type: 'success' + }); + refetch(); + readinessRequest.refetch(); + } catch (recomputeError) { + addNotification({ + title: 'Recompute failed', + message: recomputeError.message || 'Please try again.', + type: 'error' + }); + } finally { + setActioningTaskId(null); + } + }; + + const handleDeleteTask = async (taskId) => { + setActioningTaskId(taskId); + try { + const response = await apiRequest( + `/org-event-management/${orgId}/events/${event._id}/tasks/${taskId}`, + null, + { method: 'DELETE' } + ); + if (!response?.success) { + throw new Error(response?.message || response?.error || 'Unable to delete task'); + } + refetch(); + onRefresh?.(); + } catch (deleteError) { + addNotification({ + title: 'Task deletion failed', + message: deleteError.message || 'Please try again.', + type: 'error' + }); + } finally { + setActioningTaskId(null); + } + }; + + return ( +
+
+
+

+ + Event Tasks +

+

Plan and execute this event with guided, user-controlled tasks.

+
+
+ + + +
+
+ +
+
+ Tasks + {metrics.total} +
+
+ Completion + {metrics.completion}% +
+
+ Blocked + {metrics.blocked} +
+
+ Overdue + {metrics.overdue} +
+
+ {readiness && ( +
+
+
+

{readiness.score}% readiness

+

+ Band: {(readiness.band || 'not_ready').replace('_', ' ')} +

+
+
+ Tasks {readiness.dimensions?.taskCompletion ?? 0}% + Approvals {readiness.dimensions?.approvals ?? 0}% + Logistics {readiness.dimensions?.logistics ?? 0}% + Engagement {readiness.dimensions?.engagementReadiness ?? 0}% +
+
+ {(readiness.blockers || []).length > 0 && ( +
+ Blockers:{' '} + {readiness.blockers.map((blocker) => blocker.label).join(' • ')} +
+ )} +
+ )} + +
+

Add Task

+
+ setNewTask((prev) => ({ ...prev, title: e.target.value }))} + maxLength={180} + /> + + + +
+