diff --git a/backend/app.js b/backend/app.js index 57d2cec5..4663b36c 100644 --- a/backend/app.js +++ b/backend/app.js @@ -112,6 +112,7 @@ function createApp() { const qrRoutes = require('./routes/qrRoutes.js'); const eventAnalyticsRoutes = require('./routes/eventAnalyticsRoutes.js'); const orgEventManagementRoutes = require('./routes/orgEventManagementRoutes.js'); + const meetingRoutes = require('./routes/meetingRoutes.js'); const formRoutes = require('./routes/formRoutes.js'); const inngestRoutes = require('./routes/inngestRoutes.js'); const inngestServe = require('./inngest/serve.js'); @@ -140,6 +141,7 @@ function createApp() { app.use('/org-management', orgManagementRoutes); app.use('/org-invites', orgInviteRoutes); app.use('/org-messages', orgMessageRoutes); + app.use('/org-event-management', meetingRoutes); app.use('/org-event-management', orgEventManagementRoutes); app.use('/admin', roomRoutes); app.use(adminRoutes); diff --git a/backend/inngest/events.js b/backend/inngest/events.js index c8a1c8ba..15da7592 100644 --- a/backend/inngest/events.js +++ b/backend/inngest/events.js @@ -86,9 +86,44 @@ const sendTestConnectionEvent = async (testId, message, callbackUrl) => { } }; +/** + * Send meeting reminder event (manual or from cron) + * @param {string} eventId - Event ID + * @param {string} school - Tenant/school subdomain + */ +const sendMeetingReminderEvent = async (eventId, school = 'rpi') => { + try { + await inngest.send({ + name: 'meeting/reminder.send', + data: { eventId, school }, + }); + console.log(`Meeting reminder event sent for event ${eventId}`); + } catch (error) { + console.error('Error sending meeting reminder event:', error); + } +}; + +/** + * Trigger recurring meeting instance generation (manual or cron) + * @param {string} school - Tenant/school subdomain + */ +const sendRecurringMeetingGenerateEvent = async (school = 'rpi') => { + try { + await inngest.send({ + name: 'meeting/recurring.generate', + data: { school }, + }); + console.log(`Recurring meeting generate event sent for school ${school}`); + } catch (error) { + console.error('Error sending recurring meeting generate event:', error); + } +}; + module.exports = { sendUserRegisteredEvent, sendRoomCheckoutEvent, sendRoomCheckinEvent, sendTestConnectionEvent, + sendMeetingReminderEvent, + sendRecurringMeetingGenerateEvent, }; diff --git a/backend/inngest/functions.js b/backend/inngest/functions.js index 1109f11f..fa78952f 100644 --- a/backend/inngest/functions.js +++ b/backend/inngest/functions.js @@ -239,10 +239,114 @@ const testInngestDashboard = inngest.createFunction( } ); +// Function: Scheduled recurring meeting generation (runs daily at 2 AM, or on manual event) +const scheduledRecurringMeetingGeneration = inngest.createFunction( + { id: 'scheduled-recurring-meeting-generation' }, + [ + { cron: '0 2 * * *' }, + { event: 'meeting/recurring.generate' } + ], + async ({ event, step }) => { + const school = event?.data?.school || 'rpi'; + + const result = await step.run('generate-recurring-instances', async () => { + const { connectToDatabase } = require('../connectionsManager'); + const getModels = require('../services/getModelService'); + const recurringMeetingService = require('../services/recurringMeetingService'); + + const db = await connectToDatabase(school); + const req = { db, school }; + + const { RecurringMeetingRule } = getModels(req, 'RecurringMeetingRule'); + const rules = await RecurringMeetingRule.find({ isActive: true }).lean(); + + let totalCreated = 0; + for (const rule of rules) { + const created = await recurringMeetingService.generateUpcomingInstances( + { ...rule, _id: rule._id }, + req, + { maxOccurrences: 5 } + ); + totalCreated += created.length; + } + + return { totalCreated, rulesProcessed: rules.length }; + }); + + return { success: true, ...result }; + } +); + +// Function: Meeting reminder cron (runs every 30 minutes) +const meetingReminderCron = inngest.createFunction( + { id: 'meeting-reminder-cron' }, + { cron: '*/30 * * * *' }, + async ({ step }) => { + const school = 'rpi'; + + const result = await step.run('send-meeting-reminders', async () => { + const { connectToDatabase } = require('../connectionsManager'); + const getModels = require('../services/getModelService'); + const NotificationService = require('../services/notificationService'); + + const db = await connectToDatabase(school || 'rpi'); + const req = { db, school }; + + const { Event, MeetingConfig, OrgMember } = getModels(req, 'Event', 'MeetingConfig', 'OrgMember'); + const configs = await MeetingConfig.find({ + 'reminderConfig.enabled': true + }).lean(); + + const now = new Date(); + let remindersSent = 0; + + for (const config of configs) { + const eventDoc = await Event.findById(config.eventId); + if (!eventDoc) continue; + + const leadMs = (config.reminderConfig?.leadTimeMinutes || 60 * 24) * 60 * 1000; + const windowStart = new Date(eventDoc.start_time.getTime() - leadMs); + const windowEnd = new Date(eventDoc.start_time.getTime()); + + if (now >= windowStart && now <= windowEnd) { + const orgId = eventDoc.hostingId?.toString(); + const requiredRoles = config.requiredRoles || ['members']; + const members = await OrgMember.find({ org_id: orgId, status: 'active' }) + .populate('user_id', '_id') + .lean(); + + const models = { Notification: db.model('Notification', require('../schemas/notification'), 'notifications') }; + const notificationService = NotificationService.withModels(models); + const recipients = members.map((m) => ({ id: m.user_id?._id || m.user_id, model: 'User' })); + + for (const r of recipients) { + await notificationService.createNotification({ + recipient: r.id, + recipientModel: 'User', + title: 'Meeting Reminder', + message: `${eventDoc.name} starts at ${eventDoc.start_time.toLocaleString()}`, + type: 'reminder', + channels: config.reminderConfig?.channels || ['in_app'], + metadata: { eventId: eventDoc._id } + }); + } + remindersSent++; + } + } + + return { remindersSent }; + }); + + return { success: true, ...result }; + } +); + // Export all functions module.exports = { processRoomCheckout, autoCheckoutAfterDelay, testInngestConnection, testInngestDashboard, + scheduledRecurringMeetingGeneration, + meetingReminderCron, }; diff --git a/backend/routes/meetingRoutes.js b/backend/routes/meetingRoutes.js new file mode 100644 index 00000000..21050fde --- /dev/null +++ b/backend/routes/meetingRoutes.js @@ -0,0 +1,360 @@ +const express = require('express'); +const router = express.Router(); +const { verifyToken } = require('../middlewares/verifyToken'); +const getModels = require('../services/getModelService'); +const { requireEventManagement } = require('../middlewares/orgPermissions'); +const meetingService = require('../services/meetingService'); +const recurringMeetingService = require('../services/recurringMeetingService'); +const meetingQualificationService = require('../services/meetingQualificationService'); + +const MAX_LIMIT = 100; + +function parsePagination(req) { + let page = Math.max(1, parseInt(req.query.page, 10) || 1); + let limit = Math.min(MAX_LIMIT, Math.max(1, parseInt(req.query.limit, 10) || 20)); + const sortBy = req.query.sortBy || 'start_time'; + const sortOrder = req.query.sortOrder === 'desc' ? -1 : 1; + return { page, limit, sortBy, sortOrder, skip: (page - 1) * limit }; +} + +// ==================== MEETING RSVP (New Registration Doctrine) ==================== + +router.post('/:orgId/meetings/:eventId/rsvp', verifyToken, async (req, res) => { + const { Event, MeetingConfig } = getModels(req, 'Event', 'MeetingConfig'); + const { orgId, eventId } = req.params; + const user_id = req.user.userId; + const { rsvpResponse } = req.body; + + if (!rsvpResponse || !['yes', 'no'].includes(rsvpResponse)) { + return res.status(400).json({ + success: false, + message: 'rsvpResponse is required and must be "yes" or "no"' + }); + } + + try { + const event = await Event.findById(eventId); + if (!event) { + return res.status(404).json({ success: false, message: 'Event not found' }); + } + if (event.hostingId?.toString() !== orgId || event.hostingType !== 'Org') { + return res.status(404).json({ success: false, message: 'Event not found for this organization' }); + } + const config = await MeetingConfig.findOne({ eventId }); + if (!config) { + return res.status(400).json({ success: false, message: 'This is not a meeting event' }); + } + + let attendee = event.attendees.find((a) => a.userId?.toString() === user_id); + const isNew = !attendee; + + if (rsvpResponse === 'no') { + if (!attendee) { + event.attendees.push({ + userId: user_id, + registeredAt: new Date(), + guestCount: 1, + rsvpResponse: 'no', + attendanceStatus: 'excused' + }); + event.registrationCount = (event.registrationCount || 0) + 1; + } else { + attendee.rsvpResponse = 'no'; + attendee.attendanceStatus = 'excused'; + } + } else { + if (!attendee) { + event.attendees.push({ + userId: user_id, + registeredAt: new Date(), + guestCount: 1, + rsvpResponse: 'yes', + attendanceStatus: 'unexcused' + }); + event.registrationCount = (event.registrationCount || 0) + 1; + } else { + attendee.rsvpResponse = 'yes'; + attendee.attendanceStatus = attendee.checkedIn ? 'present' : 'unexcused'; + } + } + + await event.save(); + + return res.status(200).json({ + success: true, + data: { rsvpResponse, attendanceStatus: event.attendees.find((a) => a.userId?.toString() === user_id)?.attendanceStatus }, + message: 'RSVP updated' + }); + } catch (error) { + console.error('Meeting RSVP error:', error); + return res.status(500).json({ success: false, message: error.message }); + } +}); + +// ==================== MEETING MINUTES ==================== + +router.get('/:orgId/meetings/:eventId/minutes', verifyToken, requireEventManagement('orgId'), async (req, res) => { + const { MeetingMinutes, Event } = getModels(req, 'MeetingMinutes', 'Event'); + const { orgId, eventId } = req.params; + + try { + const event = await Event.findOne({ + _id: eventId, + hostingId: orgId, + hostingType: 'Org', + isDeleted: false + }); + if (!event) { + return res.status(404).json({ success: false, message: 'Meeting not found' }); + } + + let minutes = await MeetingMinutes.findOne({ eventId }).populate('createdBy', 'name username').populate('updatedBy', 'name username'); + if (!minutes) { + minutes = { eventId, googleDocUrl: null, internalNotes: null, createdBy: null, updatedBy: null }; + } + + return res.status(200).json({ success: true, data: minutes }); + } catch (error) { + console.error('Get meeting minutes error:', error); + return res.status(500).json({ success: false, message: error.message }); + } +}); + +router.put('/:orgId/meetings/:eventId/minutes', verifyToken, requireEventManagement('orgId'), async (req, res) => { + const { MeetingMinutes, Event } = getModels(req, 'MeetingMinutes', 'Event'); + const { orgId, eventId } = req.params; + const user_id = req.user.userId; + const { googleDocUrl, internalNotes } = req.body; + + try { + const event = await Event.findOne({ + _id: eventId, + hostingId: orgId, + hostingType: 'Org', + isDeleted: false + }); + if (!event) { + return res.status(404).json({ success: false, message: 'Meeting not found' }); + } + + let minutes = await MeetingMinutes.findOne({ eventId }); + if (!minutes) { + minutes = new MeetingMinutes({ eventId, createdBy: user_id }); + } + if (googleDocUrl !== undefined) minutes.googleDocUrl = googleDocUrl; + if (internalNotes !== undefined) minutes.internalNotes = internalNotes; + minutes.updatedBy = user_id; + await minutes.save(); + + return res.status(200).json({ success: true, data: minutes }); + } catch (error) { + console.error('Update meeting minutes error:', error); + return res.status(500).json({ success: false, message: error.message }); + } +}); + +// ==================== MEETINGS LIST (Paginated) ==================== + +router.get('/:orgId/meetings', verifyToken, requireEventManagement('orgId'), async (req, res) => { + const { Event, MeetingConfig } = getModels(req, 'Event', 'MeetingConfig'); + const { orgId } = req.params; + const { page, limit, sortBy, sortOrder, skip } = parsePagination(req); + const { type, dateFrom, dateTo } = req.query; + + try { + const filter = { + hostingId: orgId, + hostingType: 'Org', + type: 'meeting', + isDeleted: false + }; + if (type) filter['customFields.meetingType'] = type; + if (dateFrom && dateTo) { + filter.start_time = { $gte: new Date(dateFrom), $lte: new Date(dateTo) }; + } else if (dateFrom) { + filter.start_time = { $gte: new Date(dateFrom) }; + } else if (dateTo) { + filter.start_time = { $lte: new Date(dateTo) }; + } + + const sort = {}; sort[sortBy] = sortOrder; + const meetings = await Event.find(filter).sort(sort).skip(skip).limit(limit).lean(); + const totalCount = await Event.countDocuments(filter); + + return res.status(200).json({ + success: true, + data: { meetings }, + pagination: { currentPage: page, totalPages: Math.ceil(totalCount / limit), totalCount, limit } + }); + } catch (error) { + console.error('List meetings error:', error); + return res.status(500).json({ success: false, message: error.message }); + } +}); + +// ==================== MEETINGS DASHBOARD (Paginated) ==================== + +router.get('/:orgId/meetings/dashboard', verifyToken, requireEventManagement('orgId'), async (req, res) => { + const { Event, MeetingConfig, MeetingMinutes } = getModels(req, 'Event', 'MeetingConfig', 'MeetingMinutes'); + const { orgId } = req.params; + const { page, limit, sortBy, sortOrder, skip } = parsePagination(req); + const { dateFrom, dateTo } = req.query; + + try { + const filter = { + hostingId: orgId, + hostingType: 'Org', + type: 'meeting', + isDeleted: false + }; + if (dateFrom && dateTo) { + filter.start_time = { $gte: new Date(dateFrom), $lte: new Date(dateTo) }; + } else if (dateFrom) { + filter.start_time = { $gte: new Date(dateFrom) }; + } else if (dateTo) { + filter.start_time = { $lte: new Date(dateTo) }; + } + + const sort = {}; sort[sortBy] = sortOrder; + const meetings = await Event.find(filter).sort(sort).skip(skip).limit(limit).lean(); + const totalCount = await Event.countDocuments(filter); + + const eventIds = meetings.map((m) => m._id); + const minutesMap = {}; + const mins = await MeetingMinutes.find({ eventId: { $in: eventIds } }).lean(); + mins.forEach((m) => { minutesMap[m.eventId.toString()] = m; }); + + const enriched = meetings.map((m) => { + const present = (m.attendees || []).filter((a) => a.attendanceStatus === 'present').length; + const excused = (m.attendees || []).filter((a) => a.attendanceStatus === 'excused').length; + const unexcused = (m.attendees || []).filter((a) => a.attendanceStatus === 'unexcused').length; + return { + ...m, + minutes: minutesMap[m._id.toString()] || null, + attendanceCounts: { present, excused, unexcused } + }; + }); + + const qualificationSummary = {}; // Could aggregate from meetingQualificationService for members + + return res.status(200).json({ + success: true, + data: { meetings: enriched, qualificationSummary }, + pagination: { currentPage: page, totalPages: Math.ceil(totalCount / limit), totalCount, limit } + }); + } catch (error) { + console.error('Meetings dashboard error:', error); + return res.status(500).json({ success: false, message: error.message }); + } +}); + +// ==================== PLAN A MEETING (One-Time) ==================== + +router.post('/:orgId/meetings/plan', verifyToken, requireEventManagement('orgId'), async (req, res) => { + const { Event, MeetingConfig, EventAgenda } = getModels(req, 'Event', 'MeetingConfig', 'EventAgenda'); + const { orgId } = req.params; + const user_id = req.user.userId; + const { name, type, location, description, start_time, end_time, meetingType = 'one-time', requiredRoles = ['members'] } = req.body; + + if (!name || !location || !start_time || !end_time) { + return res.status(400).json({ success: false, message: 'name, location, start_time, end_time are required' }); + } + + try { + const attendees = await meetingService.resolveRequiredAttendees(req, orgId, requiredRoles); + const attendeeDocs = attendees.map((a) => ({ + userId: a.userId, + registeredAt: new Date(), + guestCount: 1, + rsvpResponse: 'no-response', + attendanceStatus: 'unexcused' + })); + + const event = new Event({ + name, + type: 'meeting', + start_time: new Date(start_time), + end_time: new Date(end_time), + location: location, + description: description || '', + expectedAttendance: attendeeDocs.length || 10, + visibility: 'members_only', + hostingId: orgId, + hostingType: 'Org', + status: 'not-applicable', + registrationEnabled: true, + registrationRequired: false, + attendees: attendeeDocs, + registrationCount: attendeeDocs.length, + checkInEnabled: true, + customFields: { meetingType } + }); + await event.save(); + + const meetingConfig = new MeetingConfig({ + eventId: event._id, + meetingType, + requiredRoles, + reminderConfig: { enabled: true, leadTimeMinutes: 60 * 24, channels: ['in_app', 'email'] } + }); + await meetingConfig.save(); + + const agenda = new EventAgenda({ eventId: event._id, orgId }); + await agenda.save(); + + return res.status(201).json({ success: true, data: { event, meetingConfig } }); + } catch (error) { + console.error('Plan meeting error:', error); + return res.status(500).json({ success: false, message: error.message }); + } +}); + +// ==================== PLAN RECURRING MEETINGS ==================== + +router.post('/:orgId/meetings/plan/recurring', verifyToken, requireEventManagement('orgId'), async (req, res) => { + const { RecurringMeetingRule, Event } = getModels(req, 'RecurringMeetingRule', 'Event'); + const { orgId } = req.params; + const user_id = req.user.userId; + const { name, type, location, description, meetingType, requiredRoles, recurrenceType, interval, daysOfWeek, dayOfMonth, weekOfMonth, timeOfDay, durationMinutes, startDate, endDate, occurrenceLimit, excludeDates } = req.body; + + if (!name || !type || !location || !recurrenceType || !timeOfDay || !durationMinutes || !startDate) { + return res.status(400).json({ success: false, message: 'name, type, location, recurrenceType, timeOfDay, durationMinutes, startDate are required' }); + } + + try { + const rule = new RecurringMeetingRule({ + orgId, + createdBy: user_id, + name, + type, + location, + description: description || '', + expectedAttendance: 10, + visibility: 'members_only', + meetingType: meetingType || 'gbm', + requiredRoles: requiredRoles || ['members'], + recurrenceType, + interval: interval || 1, + daysOfWeek: daysOfWeek || [], + dayOfMonth, + weekOfMonth, + timeOfDay, + durationMinutes, + startDate: new Date(startDate), + endDate: endDate ? new Date(endDate) : null, + occurrenceLimit: occurrenceLimit || null, + excludeDates: excludeDates ? excludeDates.map((d) => new Date(d)) : [], + isActive: true + }); + await rule.save(); + + const created = await recurringMeetingService.generateUpcomingInstances(rule, req, { maxOccurrences: 10 }); + + return res.status(201).json({ success: true, data: { rule, createdEvents: created } }); + } catch (error) { + console.error('Plan recurring meeting error:', error); + return res.status(500).json({ success: false, message: error.message }); + } +}); + +module.exports = router; diff --git a/backend/services/getModelService.js b/backend/services/getModelService.js index b272ae56..db8af2fc 100644 --- a/backend/services/getModelService.js +++ b/backend/services/getModelService.js @@ -58,6 +58,9 @@ const eventEquipmentSchema = require('../schemas/EventEquipment'); const orgEquipmentSchema = require('../schemas/OrgEquipment'); const analyticsEventSchema = require('../events/schemas/analyticsEvent'); const eventQRSchema = require('../events/schemas/eventQR'); +const meetingConfigSchema = require('../events/schemas/meetingConfig'); +const meetingMinutesSchema = require('../events/schemas/meetingMinutes'); +const recurringMeetingRuleSchema = require('../events/schemas/recurringMeetingRule'); @@ -125,6 +128,9 @@ const getModels = (req, ...names) => { ResourcesConfig: req.db.model('ResourcesConfig', resourcesConfigSchema, 'resourcesConfigs'), ShuttleConfig: req.db.model('ShuttleConfig', shuttleConfigSchema, 'shuttleConfigs'), NoticeConfig: req.db.model('NoticeConfig', noticeConfigSchema, 'noticeConfigs'), + MeetingConfig: req.db.model('MeetingConfig', meetingConfigSchema, 'meetingConfigs'), + MeetingMinutes: req.db.model('MeetingMinutes', meetingMinutesSchema, 'meetingMinutes'), + RecurringMeetingRule: req.db.model('RecurringMeetingRule', recurringMeetingRuleSchema, 'recurringMeetingRules'), }; return names.reduce((acc, name) => { diff --git a/backend/services/meetingQualificationService.js b/backend/services/meetingQualificationService.js new file mode 100644 index 00000000..c5c96f90 --- /dev/null +++ b/backend/services/meetingQualificationService.js @@ -0,0 +1,59 @@ +const getModels = require('./getModelService'); + +/** + * Compute whether a member meets qualification rules based on meeting attendance. + * @param {Object} req - Request object + * @param {string} orgId - Organization ID + * @param {string} userId - User ID + * @param {Object} qualificationRules - { minAttendance: Number, meetingTypes: [String] } + * @returns {Promise<{ qualified: boolean, attendedCount: number, requiredCount: number }>} + */ +async function computeMemberQualificationStatus(req, orgId, userId, qualificationRules = {}) { + const { Event, MeetingConfig } = getModels(req, 'Event', 'MeetingConfig'); + + const requiredCount = qualificationRules.minAttendance || 0; + const meetingTypes = (qualificationRules.meetingTypes && qualificationRules.meetingTypes.length) + ? qualificationRules.meetingTypes + : ['gbm', 'officer', 'one-time']; + + const configs = await MeetingConfig.find({ + meetingType: { $in: meetingTypes } + }).select('eventId').lean(); + const eventIds = configs.map((c) => c.eventId); + + const attendedCount = await Event.countDocuments({ + _id: { $in: eventIds }, + hostingId: orgId, + hostingType: 'Org', + isDeleted: false, + 'attendees.userId': userId, + 'attendees.attendanceStatus': 'present' + }); + + return { + qualified: attendedCount >= requiredCount, + attendedCount, + requiredCount + }; +} + +/** + * Compute qualification status for multiple members. + * @param {Object} req - Request object + * @param {string} orgId - Organization ID + * @param {string[]} userIds - User IDs + * @param {Object} qualificationRules - { minAttendance, meetingTypes } + * @returns {Promise} Map of userId -> { qualified, attendedCount, requiredCount } + */ +async function computeBulkQualificationStatus(req, orgId, userIds, qualificationRules = {}) { + const results = {}; + for (const userId of userIds) { + results[userId] = await computeMemberQualificationStatus(req, orgId, userId, qualificationRules); + } + return results; +} + +module.exports = { + computeMemberQualificationStatus, + computeBulkQualificationStatus +}; diff --git a/backend/services/meetingService.js b/backend/services/meetingService.js new file mode 100644 index 00000000..3d565601 --- /dev/null +++ b/backend/services/meetingService.js @@ -0,0 +1,70 @@ +const getModels = require('./getModelService'); +const { ORG_PERMISSIONS } = require('../constants/permissions'); + +/** + * Resolve required attendees for a meeting based on requiredRoles. + * @param {Object} req - Request object (for getModels) + * @param {string} orgId - Organization ID + * @param {string[]} requiredRoles - ['members', 'officers'] or custom org role/position IDs + * @returns {Promise>} List of user IDs who are required attendees + */ +async function resolveRequiredAttendees(req, orgId, requiredRoles = []) { + const { OrgMember, Org } = getModels(req, 'OrgMember', 'Org'); + + if (!requiredRoles || requiredRoles.length === 0) { + return []; + } + + const org = await Org.findById(orgId).lean(); + if (!org) return []; + + const activeMembers = await OrgMember.find({ + org_id: orgId, + status: 'active' + }).populate('user_id', '_id').lean(); + + const userIdSet = new Set(); + const wantsMembers = requiredRoles.includes('members'); + const wantsOfficers = requiredRoles.includes('officers'); + + for (const member of activeMembers) { + const userId = member.user_id?._id || member.user_id; + if (!userId) continue; + + const roleName = member.role; + const position = org.positions?.find((p) => p.name === roleName || p._id?.toString() === roleName); + const isOfficer = position?.canManageEvents || member.customPermissions?.includes(ORG_PERMISSIONS.MANAGE_EVENTS); + + if (wantsMembers) { + userIdSet.add(userId.toString()); + } + if (wantsOfficers && isOfficer) { + userIdSet.add(userId.toString()); + } + for (const reqRole of requiredRoles) { + if (reqRole === 'members' || reqRole === 'officers') continue; + if (reqRole === roleName || (position && position._id?.toString() === reqRole)) { + userIdSet.add(userId.toString()); + break; + } + } + } + + return Array.from(userIdSet).map((id) => ({ userId: id })); +} + +/** + * Check if an event is a meeting (has MeetingConfig or type === 'meeting'). + * @param {Object} event - Event document + * @param {Object} meetingConfig - Optional pre-fetched MeetingConfig + * @returns {boolean} + */ +function isMeetingEvent(event, meetingConfig = null) { + if (meetingConfig) return true; + return event?.type === 'meeting' || event?.customFields?.isMeeting === true; +} + +module.exports = { + resolveRequiredAttendees, + isMeetingEvent +}; diff --git a/backend/services/recurringMeetingService.js b/backend/services/recurringMeetingService.js new file mode 100644 index 00000000..6ea1c5c9 --- /dev/null +++ b/backend/services/recurringMeetingService.js @@ -0,0 +1,216 @@ +const getModels = require('./getModelService'); +const mongoose = require('mongoose'); + +const DEFAULT_LOOKAHEAD_DAYS = 60; +const DEFAULT_MAX_OCCURRENCES = 20; + +/** + * Parse time string (e.g. "14:00") into hours and minutes. + */ +function parseTimeOfDay(timeStr) { + const match = String(timeStr).match(/^(\d{1,2}):(\d{2})$/); + if (match) { + return { hours: parseInt(match[1], 10), minutes: parseInt(match[2], 10) }; + } + return { hours: 14, minutes: 0 }; +} + +/** + * Create a date at the given time on the given day. + */ +function dateAtTime(date, timeStr) { + const { hours, minutes } = parseTimeOfDay(timeStr); + const d = new Date(date); + d.setHours(hours, minutes, 0, 0); + return d; +} + +/** + * Check if a date is in excludeDates (compare date parts only). + */ +function isExcluded(date, excludeDates = []) { + if (!excludeDates.length) return false; + const dStr = date.toISOString().split('T')[0]; + return excludeDates.some((ex) => { + const exDate = ex instanceof Date ? ex : new Date(ex); + return exDate.toISOString().split('T')[0] === dStr; + }); +} + +/** + * Generate occurrence dates for a recurring rule. + */ +function generateOccurrenceDates(rule, fromDate, maxCount = DEFAULT_MAX_OCCURRENCES) { + const dates = []; + const start = new Date(Math.max(rule.startDate.getTime(), fromDate.getTime())); + const endLimit = rule.endDate + ? new Date(Math.min( + rule.endDate.getTime(), + fromDate.getTime() + DEFAULT_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000 + )) + : new Date(fromDate.getTime() + DEFAULT_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000); + const limit = rule.occurrenceLimit ? Math.min(maxCount, rule.occurrenceLimit) : maxCount; + + const interval = rule.interval || 1; + const days = (rule.daysOfWeek && rule.daysOfWeek.length) ? rule.daysOfWeek : [rule.startDate.getDay()]; + let cursor = new Date(start); + + while (dates.length < limit && cursor <= endLimit) { + const candidates = []; + + switch (rule.recurrenceType) { + case 'daily': { + const d = dateAtTime(cursor, rule.timeOfDay); + if (d >= start && d <= endLimit && !isExcluded(d, rule.excludeDates)) { + candidates.push(d); + } + cursor.setDate(cursor.getDate() + interval); + break; + } + case 'weekly': + case 'biweekly': { + const weekMs = 7 * 24 * 60 * 60 * 1000; + const baseWeekStart = new Date(rule.startDate); + baseWeekStart.setHours(0, 0, 0, 0); + for (const dayOfWeek of days) { + const d = new Date(cursor); + const diff = (dayOfWeek - d.getDay() + 7) % 7; + d.setDate(d.getDate() + diff); + const atTime = dateAtTime(d, rule.timeOfDay); + if (atTime < start) continue; + if (atTime > endLimit) continue; + if (isExcluded(atTime, rule.excludeDates)) continue; + if (rule.recurrenceType === 'biweekly') { + const weeksFromStart = Math.floor((atTime - rule.startDate) / weekMs); + if (weeksFromStart % 2 !== 0) continue; + } + candidates.push(atTime); + } + cursor.setDate(cursor.getDate() + 7 * interval * (rule.recurrenceType === 'biweekly' ? 2 : 1)); + break; + } + case 'monthly': { + if (rule.dayOfMonth) { + const d = new Date(cursor.getFullYear(), cursor.getMonth(), Math.min(rule.dayOfMonth, 28)); + const atTime = dateAtTime(d, rule.timeOfDay); + if (atTime >= start && atTime <= endLimit && !isExcluded(atTime, rule.excludeDates)) { + candidates.push(atTime); + } + } else if ((rule.weekOfMonth || rule.daysOfWeek) && rule.daysOfWeek && rule.daysOfWeek.length > 0) { + const weekNum = rule.weekOfMonth === 'last' ? 5 : (parseInt(rule.weekOfMonth, 10) || 1); + const dayOfWeek = rule.daysOfWeek[0]; + let d = new Date(cursor.getFullYear(), cursor.getMonth(), 1); + const lastDay = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0).getDate(); + let occurrences = []; + for (let day = 1; day <= lastDay; day++) { + d.setDate(day); + if (d.getDay() === dayOfWeek) occurrences.push(new Date(d)); + } + const target = rule.weekOfMonth === 'last' ? occurrences[occurrences.length - 1] : occurrences[weekNum - 1]; + if (target) { + const atTime = dateAtTime(target, rule.timeOfDay); + if (atTime >= start && atTime <= endLimit && !isExcluded(atTime, rule.excludeDates)) { + candidates.push(atTime); + } + } + } + cursor.setMonth(cursor.getMonth() + interval); + break; + } + default: + cursor.setDate(cursor.getDate() + 7); + } + + candidates.sort((a, b) => a - b); + for (const c of candidates) { + if (dates.length >= limit) break; + dates.push(c); + } + } + + return dates.slice(0, limit); +} + +/** + * Generate upcoming Event instances from a RecurringMeetingRule. + * @param {Object} rule - RecurringMeetingRule document + * @param {Object} req - Request object (for getModels) + * @param {Object} options - { fromDate?, maxOccurrences? } + * @returns {Promise>} Created Event documents + */ +async function generateUpcomingInstances(rule, req, options = {}) { + const { Event, MeetingConfig, EventAgenda } = getModels(req, 'Event', 'MeetingConfig', 'EventAgenda'); + const fromDate = options.fromDate || new Date(); + const maxOccurrences = options.maxOccurrences ?? DEFAULT_MAX_OCCURRENCES; + + const dates = generateOccurrenceDates(rule, fromDate, maxOccurrences); + const created = []; + + for (const startTime of dates) { + const endTime = new Date(startTime.getTime() + (rule.durationMinutes || 60) * 60 * 1000); + + const existing = await Event.findOne({ + hostingId: rule.orgId, + hostingType: 'Org', + start_time: startTime, + isDeleted: false, + 'customFields.recurringRuleId': rule._id + }); + + if (existing) continue; + + const event = new Event({ + name: rule.name, + type: 'meeting', + start_time: startTime, + end_time: endTime, + location: rule.location, + description: rule.description || '', + expectedAttendance: rule.expectedAttendance || 10, + visibility: rule.visibility || 'members_only', + hostingId: rule.orgId, + hostingType: 'Org', + status: 'not-applicable', + registrationEnabled: true, + registrationRequired: false, + attendees: [], + registrationCount: 0, + checkInEnabled: true, + customFields: { + recurringRuleId: rule._id, + meetingType: rule.meetingType + } + }); + + await event.save(); + + const meetingConfig = new MeetingConfig({ + eventId: event._id, + meetingType: rule.meetingType, + requiredRoles: rule.requiredRoles || ['members'], + reminderConfig: { enabled: true, leadTimeMinutes: 60 * 24, channels: ['in_app', 'email'] } + }); + await meetingConfig.save(); + + if (event.hostingType === 'Org' && event.hostingId) { + try { + let agenda = await EventAgenda.findOne({ eventId: event._id, orgId: rule.orgId }); + if (!agenda) { + agenda = new EventAgenda({ eventId: event._id, orgId: rule.orgId }); + await agenda.save(); + } + } catch (e) { + // Non-fatal + } + } + + created.push(event); + } + + return created; +} + +module.exports = { + generateUpcomingInstances, + generateOccurrenceDates +}; diff --git a/docs/Club-Meetings-Infra.md b/docs/Club-Meetings-Infra.md new file mode 100644 index 00000000..02be1278 --- /dev/null +++ b/docs/Club-Meetings-Infra.md @@ -0,0 +1,140 @@ +# Meeting Management System + +## What Happens in Meetings + +### Attendance +- Tracked using Excel spreadsheets / Google Sheets +- Excused vs. Unexcused absences +- Announcements or updates sent via Discord + +### Membership Requirements +- Criteria needed to become a member +- Member list stored in CMS +- Officer list stored in CMS + +### Meeting Minutes +- Recorded in Google Docs or Slides +- Shared with members +- Members added to Discord +- Reminders sent via Discord + +### Scheduling Meetings +Tools used: +- When2Meet +- WhenIsGood +- LettuceMeet +- Crab Rave + +--- + +# Tasks + +## Recurring “Events” – GBM (General Body Meetings) + +### Features Needed +- Recurring meeting events +- Attendance tracking system +- Google Sheet–style checkboxes: + - If member RSVP’d yes → checkbox to confirm attendance + - If RSVP’d no → mark as excused absence + - If no response → mark as unexcused absence +- Reminders asking members if they will attend +- Notifications sent to all Members and Officers +- Meeting notes recorded by an Officer within the meeting + +--- + +## Recurring “Events” – Officer Meetings + +### Features Needed +- Recurring officer-only meetings +- Attendance tracking +- Reminders for Officers +- Meeting notes recorded by an Officer + +### One-Time Officer Meetings (Special/Important Info) +- May include non-club members +- Attendance tracking +- Reminders sent to all relevant Members/Officers +- Meeting notes recorded by an Officer + +--- + +# Core Meeting Functionality + +### Build Basic Meeting System +- Reuse existing Event component +- Identify required attendees: + - Officers + - Members + +### Attendance Functionality +- Track attendance within meetings +- Define requirements to become a full member +- Automatically highlight when membership requirements are met + +### Reminder / Notification Functionality +- Send reminders before meetings +- Notify required attendees + +### Meeting Minutes Section +- Dedicated section within each meeting +- Officer-editable + +--- + +# Feature List + +- Meeting Minutes +- Attendance Tracking +- Reminders / Notifications + +--- + +# Actionable Items + +## Meeting Minutes +- Option 1: Link to Google Docs (embed or outsource) +- Option 2: Fully custom implementation with internal storage + +## Attendance +- Option 1: Link to Google Sheets +- Option 2: Build custom attendance system + +## Reminders +- Send email reminders to Members/Officers +- Send in-app reminders to Members/Officers + +## Existing Features +- Lists of Officers and Members (already implemented) + +## Meeting Info +- Reuse Event component +- Add “Plan a Meeting” button +- Dashboard view showing: + - Meeting Minutes + - Attendance Records + + + + task list: + + MER-145 Create Meeting Data Model + +MER-146 Reuse Event Component for Meetings + +MER-147 Implement Recurring Meetings + +MER-148 integrate with existing RSVP System + +MER-149 Integrate with existing Attendance Tracking (Admin Check-In + Status Logic) + +MER-150 Integrate on top of Attendance Dashboard & Membership Qualification Logic + +MER-151 Build Automated Reminder & Notification System + +MER-152 Implement Meeting Minutes (Google Doc Link + Internal Notes Option) + +MER-153 Add Required Attendance Role Controls (Members/Officers/Custom) + +MER-154 Build Meetings Dashboard & “Plan a Meeting” Flow \ No newline at end of file diff --git a/frontend/src/pages/ClubDash/ClubDash.jsx b/frontend/src/pages/ClubDash/ClubDash.jsx index efddf54f..03e0d39e 100644 --- a/frontend/src/pages/ClubDash/ClubDash.jsx +++ b/frontend/src/pages/ClubDash/ClubDash.jsx @@ -11,6 +11,7 @@ import Dash from './Dash/Dash'; import Members from './Members/Members'; import Roles from './Roles/Roles'; import Testing from './Testing/Testing'; +import Meetings from './Meetings/Meetings'; import {useFetch} from '../../hooks/useFetch'; import OrgDropdown from './OrgDropdown/OrgDropdown'; @@ -262,6 +263,12 @@ function ClubDash(){ requiresPermission: 'canManageMembers', element: }, + { + label: 'Meetings', + icon: 'mdi:calendar', + key: 'meetings', + element: + }, // { // label: 'Forms', // icon: 'mdi:file-document', diff --git a/frontend/src/pages/ClubDash/Meetings/AttendanceCard/AttendanceCard.jsx b/frontend/src/pages/ClubDash/Meetings/AttendanceCard/AttendanceCard.jsx new file mode 100644 index 00000000..dfdacfa8 --- /dev/null +++ b/frontend/src/pages/ClubDash/Meetings/AttendanceCard/AttendanceCard.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Icon } from '@iconify-icon/react/dist/iconify.mjs'; +import './AttendanceCard.scss'; + +function AttendanceCard({ record, onClick }) { + const { title, active, attended, excused, unexcused, rate } = record; + + return ( +
e.key === 'Enter' && onClick?.()} + > +

{title}

+ {active && ( + + In progress + + )} +
+
+ {attended} attended +
+
+ {excused} excused +
+
+ {unexcused} unexcused +
+
+
+
+
+ {rate}% attendance rate + View details → +
+ ); +} + +export default AttendanceCard; \ No newline at end of file diff --git a/frontend/src/pages/ClubDash/Meetings/AttendanceCard/AttendanceCard.scss b/frontend/src/pages/ClubDash/Meetings/AttendanceCard/AttendanceCard.scss new file mode 100644 index 00000000..baaf79fd --- /dev/null +++ b/frontend/src/pages/ClubDash/Meetings/AttendanceCard/AttendanceCard.scss @@ -0,0 +1,105 @@ +.attendance-card { + background: var(--card-bg, #fff); + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 10px; + padding: 16px; + cursor: pointer; + transition: box-shadow 0.2s ease, transform 0.15s ease, border-color 0.2s ease; + + &:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + transform: translateY(-1px); + border-color: var(--accent-color, #6366f1); + } + + &:focus-visible { + outline: 2px solid var(--accent-color, #6366f1); + outline-offset: 2px; + } + + &--active { + border-color: #6366f1; + background: #fafafe; + } + + h3 { + font-size: 0.95rem; + font-weight: 600; + color: var(--text-primary, #111827); + margin: 0 0 6px; + } +} + +.in-progress-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.78rem; + color: #6366f1; + font-weight: 500; + margin-bottom: 10px; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #22c55e; + display: inline-block; + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.attendance-stats { + display: flex; + gap: 12px; + font-size: 0.8rem; + flex-wrap: wrap; + margin-bottom: 10px; + + .stat { + display: flex; + align-items: center; + gap: 5px; + } + + .attended { color: #059669; } + .excused { color: #d97706; } + .unexcused { color: #ef4444; } +} + +.attendance-bar { + height: 6px; + background: #e5e7eb; + border-radius: 99px; + overflow: hidden; + margin-bottom: 6px; + + &__fill { + height: 100%; + background: linear-gradient(90deg, #6366f1, #818cf8); + border-radius: 99px; + transition: width 0.5s ease; + } +} + +.attendance-rate { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-primary, #374151); + display: block; + margin-bottom: 8px; +} + +.view-details { + font-size: 0.78rem; + color: #6366f1; + font-weight: 500; + cursor: pointer; + + &:hover { text-decoration: underline; } +} \ No newline at end of file diff --git a/frontend/src/pages/ClubDash/Meetings/Meeting/Meeting.jsx b/frontend/src/pages/ClubDash/Meetings/Meeting/Meeting.jsx new file mode 100644 index 00000000..6058c1f5 --- /dev/null +++ b/frontend/src/pages/ClubDash/Meetings/Meeting/Meeting.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { Icon } from '@iconify-icon/react/dist/iconify.mjs'; +import TabbedContainer from '../../../../components/TabbedContainer/TabbedContainer'; +import AttendanceTab from './Tabs/AttendanceTab'; +import RemindersTab from './Tabs/RemindersTab'; +import './Meeting.scss'; + +const defaultAttendees = [ + { id: 1, name: 'Alex Chen', role: 'Officer', rsvp: 'yes', present: true }, + { id: 2, name: 'Jordan Smith', role: 'Member', rsvp: 'yes', present: true }, + { id: 3, name: 'Sam Williams', role: 'Member', rsvp: 'yes', present: false }, + { id: 4, name: 'Taylor Brown', role: 'Member', rsvp: 'no', present: false }, + { id: 5, name: 'Morgan Davis', role: 'Officer', rsvp: 'no', present: false }, + { id: 6, name: 'Casey Lee', role: 'Member', rsvp: 'no-response', present: false }, + { id: 7, name: 'Riley Johnson', role: 'Member', rsvp: 'no-response', present: false }, +]; + +function Meeting({ meeting, attendees = defaultAttendees, onBack }) { + if (!meeting) return null; + + const isActive = meeting.completed === false; + + const tabs = [ + { + id: 'attendance', + label: 'Attendance', + icon: 'mdi:checkbox-marked-outline', + content: , + }, + { + id: 'meeting-minutes', + label: 'Meeting Minutes', + icon: 'mdi:file-document-outline', + content: ( +
+
+ +

No meeting minutes yet.

+
+
+ ), + }, + { + id: 'reminders', + label: 'Reminders', + icon: 'mdi:bell-outline', + content: , + }, + ]; + + return ( +
+ + +
+
+

{meeting.title}

+ {isActive && ( + + In progress + + )} + {meeting.completed && ( + Completed + )} +
+
+ {meeting.time && ( + {meeting.time} + )} + {meeting.location && ( + {meeting.location} + )} + Required: members, officers +
+
+ + +
+ ); +} + +export default Meeting; \ No newline at end of file diff --git a/frontend/src/pages/ClubDash/Meetings/Meeting/Meeting.scss b/frontend/src/pages/ClubDash/Meetings/Meeting/Meeting.scss new file mode 100644 index 00000000..c25e151f --- /dev/null +++ b/frontend/src/pages/ClubDash/Meetings/Meeting/Meeting.scss @@ -0,0 +1,309 @@ +.meeting{ + padding: 24px 32px; + max-width: 960px; +} + +.detail-back { + display: inline-flex; + align-items: center; + gap: 6px; + background: none; + border: none; + font-size: 0.85rem; + color: var(--text-muted, #6b7280); + cursor: pointer; + padding: 0; + margin-bottom: 20px; + transition: color 0.15s; + + &:hover { color: var(--text-primary, #111827); } +} + +.detail-header { + margin-bottom: 24px; + + &__title-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 10px; + + h1 { + font-size: 1.6rem; + font-weight: 700; + color: var(--text-primary, #111827); + margin: 0; + } + } + + &__meta { + display: flex; + flex-wrap: wrap; + gap: 16px; + font-size: 0.82rem; + color: var(--text-muted, #6b7280); + + span { + display: inline-flex; + align-items: center; + gap: 5px; + } + } +} + +.in-progress-badge { + display: inline-flex; + align-items: center; + gap: 6px; + background: #f0fdf4; + color: #16a34a; + border: 1px solid #bbf7d0; + font-size: 0.78rem; + font-weight: 600; + padding: 3px 10px; + border-radius: 20px; +} + +.completed-badge { + display: inline-flex; + align-items: center; + background: #f3f4f6; + color: #6b7280; + font-size: 0.78rem; + font-weight: 600; + padding: 3px 10px; + border-radius: 20px; +} + +.status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #22c55e; + display: inline-block; + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +// Tab content wrapper +.detail-tab-content { + padding: 20px 0; +} + +.attendance-alert { + display: inline-flex; + align-items: center; + gap: 8px; + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: #16a34a; + font-size: 0.83rem; + font-weight: 500; + padding: 8px 14px; + border-radius: 6px; + margin-bottom: 14px; +} + +.attendance-hint { + font-size: 0.8rem; + color: var(--text-muted, #6b7280); + margin-bottom: 16px; +} + +// Table +.attendance-table-wrap { + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 10px; + overflow: hidden; +} + +.attendance-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + + thead tr { background: #f9fafb; } + + th { + text-align: left; + padding: 10px 16px; + font-size: 0.78rem; + font-weight: 600; + color: var(--text-muted, #6b7280); + text-transform: uppercase; + letter-spacing: 0.04em; + border-bottom: 1px solid var(--border-color, #e5e7eb); + } + + td { + padding: 14px 16px; + border-bottom: 1px solid var(--border-color, #f3f4f6); + color: var(--text-primary, #111827); + } + + tbody tr:last-child td { border-bottom: none; } + + .col-name { font-weight: 500; } + .col-role { color: var(--text-muted, #6b7280); } +} + +// RSVP badges +.rsvp-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 20px; + + &--yes { background: #dcfce7; color: #16a34a; } + &--no { background: #f3f4f6; color: #6b7280; } + &--none { background: #fef2f2; color: #dc2626; } +} + +// Attendance status (read-only) +.attendance-status { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + + &.status--present { color: var(--text-primary, #111827); } + &.status--excused { color: var(--text-muted, #6b7280); } + &.status--unexcused { color: #dc2626; } +} + +// Attendance check button +.attendance-check { + display: inline-flex; + align-items: center; + gap: 7px; + background: none; + border: none; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + font-size: 0.85rem; + color: var(--text-muted, #9ca3af); + transition: background 0.15s; + + &:hover:not(:disabled) { + background: #f3f4f6; + color: var(--text-primary, #111827); + } + + &:disabled { + cursor: default; + opacity: 0.7; + } + + &--checked { + color: var(--text-primary, #111827); + } +} + +.check-box { + width: 18px; + height: 18px; + border: 1.5px solid #d1d5db; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s; + color: #fff; + + .attendance-check--checked & { + background: #22c55e; + border-color: #22c55e; + } +} + +.status--present { color: var(--text-primary, #111827); } +.status--unchecked { color: var(--text-muted, #9ca3af); } + +// Empty states +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 60px 0; + color: var(--text-muted, #9ca3af); + font-size: 0.9rem; +} + +// Reminders tab +.reminders-description { + font-size: 0.82rem; + color: var(--text-muted, #6b7280); + margin-bottom: 16px; +} + +.reminders-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; +} + +.reminder-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 10px; + background: var(--card-bg, #fff); + + &__left { + display: flex; + align-items: center; + gap: 12px; + } + + &__title { + font-size: 0.88rem; + font-weight: 600; + color: var(--text-primary, #111827); + margin-bottom: 2px; + } + + &__sub { + font-size: 0.78rem; + color: var(--text-muted, #6b7280); + } +} + +.reminder-icon { + &--email { color: #16a34a; } + &--app { color: #16a34a; } +} + +.reminder-badge { + font-size: 0.75rem; + font-weight: 600; + padding: 3px 10px; + border-radius: 6px; + background: #f3f4f6; + color: #6b7280; +} + +.reminders-edit { + background: none; + border: none; + font-size: 0.82rem; + color: var(--text-muted, #9ca3af); + cursor: pointer; + padding: 0; + + &:hover { text-decoration: underline; } +} \ No newline at end of file diff --git a/frontend/src/pages/ClubDash/Meetings/Meeting/Tabs/AttendanceTab.jsx b/frontend/src/pages/ClubDash/Meetings/Meeting/Tabs/AttendanceTab.jsx new file mode 100644 index 00000000..4c9d5c4a --- /dev/null +++ b/frontend/src/pages/ClubDash/Meetings/Meeting/Tabs/AttendanceTab.jsx @@ -0,0 +1,103 @@ +import React, { useState } from 'react'; +import { Icon } from '@iconify-icon/react/dist/iconify.mjs'; + +function AttendanceRow({ attendee, isActive, onToggle }) { + const { name, role, rsvp, present } = attendee; + + const rsvpBadge = { + yes: { label: 'Yes', cls: 'rsvp--yes' }, + no: { label: 'No', cls: 'rsvp--no' }, + 'no-response': { label: 'No response', cls: 'rsvp--none' }, + }[rsvp]; + + const getAttendanceStatus = () => { + if (rsvp === 'no') return { label: 'Excused', cls: 'status--excused', icon: 'mdi:calendar-minus' }; + if (rsvp === 'no-response' && !present) return { label: 'Unexcused', cls: 'status--unexcused', icon: 'mdi:calendar-remove' }; + if (present) return { label: 'Present', cls: 'status--present', icon: null }; + return { label: 'Unexcused', cls: 'status--unexcused', icon: 'mdi:calendar-remove' }; + }; + + const status = getAttendanceStatus(); + const isCheckable = rsvp === 'yes'; + + return ( + + {name} + {role} + + + {rsvp === 'yes' && } + {rsvp === 'no' && } + {rsvpBadge.label} + + + + {isCheckable && isActive ? ( + + ) : ( + + {status.icon && } + {status.label} + + )} + + + ); +} + +function AttendanceTab({ attendees = [], isActive }) { + const [localAttendees, setLocalAttendees] = useState(attendees); + + const handleToggle = (id) => { + setLocalAttendees(prev => + prev.map(a => a.id === id ? { ...a, present: !a.present } : a) + ); + }; + + return ( +
+ {isActive && ( +
+ + Meeting in progress — take attendance now +
+ )} +

+ RSVP Yes → check to confirm attendance. RSVP No → mark excused. No response → mark unexcused. +

+
+ + + + + + + + + + + {localAttendees.map(a => ( + + ))} + +
NameRoleRSVPAttendance
+
+
+ ); +} + +export default AttendanceTab; \ No newline at end of file diff --git a/frontend/src/pages/ClubDash/Meetings/Meeting/Tabs/RemindersTab.jsx b/frontend/src/pages/ClubDash/Meetings/Meeting/Tabs/RemindersTab.jsx new file mode 100644 index 00000000..741a4150 --- /dev/null +++ b/frontend/src/pages/ClubDash/Meetings/Meeting/Tabs/RemindersTab.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Icon } from '@iconify-icon/react/dist/iconify.mjs'; + +function RemindersTab() { + return ( +
+

+ Reminders are sent to required attendees (Members/Officers) before the meeting. +

+
+
+
+ +
+
Email reminder
+
Sent 24 hours before meeting
+
+
+ Configured +
+
+
+ +
+
In-app notification
+
Sent 2 hours before meeting
+
+
+ Configured +
+
+ +
+ ); +} + +export default RemindersTab; \ No newline at end of file diff --git a/frontend/src/pages/ClubDash/Meetings/MeetingCard/MeetingCard.jsx b/frontend/src/pages/ClubDash/Meetings/MeetingCard/MeetingCard.jsx new file mode 100644 index 00000000..c1ddbfea --- /dev/null +++ b/frontend/src/pages/ClubDash/Meetings/MeetingCard/MeetingCard.jsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Icon } from '@iconify-icon/react/dist/iconify.mjs'; +import './MeetingCard.scss'; + +function MeetingCard({ meeting, onClick }) { + const { + type, + title, + time, + location, + attending, + excused, + noResponse, + completed, + hasMinutes, + } = meeting; + + const tagClass = { + gbm: 'meeting-tag--gbm', + officer: 'meeting-tag--officer', + special: 'meeting-tag--special', + }[type] || ''; + + const tagLabel = { + gbm: 'General Body', + officer: 'Officer', + special: 'Special / Officer', + }[type] || type; + + return ( +
e.key === 'Enter' && onClick?.()}> +
+ + {type === 'gbm' && } + {tagLabel} + + {(completed || hasMinutes) && ( +
+ {completed && Completed} + {hasMinutes && Minutes} +
+ )} +
+
{title}
+
+ {time && {time}} + {location && {location}} +
+
+ + {attending} attending + + + {excused} excused + + + {noResponse} no response + +
+
+ ); +} + +export default MeetingCard; \ No newline at end of file diff --git a/frontend/src/pages/ClubDash/Meetings/MeetingCard/MeetingCard.scss b/frontend/src/pages/ClubDash/Meetings/MeetingCard/MeetingCard.scss new file mode 100644 index 00000000..5f240878 --- /dev/null +++ b/frontend/src/pages/ClubDash/Meetings/MeetingCard/MeetingCard.scss @@ -0,0 +1,116 @@ +.meeting-card { + background: var(--card-bg, #fff); + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 12px; + cursor: pointer; + transition: box-shadow 0.2s ease, transform 0.15s ease, border-color 0.2s ease; + + &:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + transform: translateY(-1px); + border-color: var(--accent-color, #6366f1); + } + + &:focus-visible { + outline: 2px solid var(--accent-color, #6366f1); + outline-offset: 2px; + } + + &__top { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; + } + + &__title { + font-weight: 600; + font-size: 0.95rem; + color: var(--text-primary, #111827); + margin-bottom: 6px; + } + + &__meta { + display: flex; + gap: 14px; + font-size: 0.8rem; + color: var(--text-muted, #6b7280); + margin-bottom: 8px; + align-items: center; + flex-wrap: wrap; + + span { + display: flex; + align-items: center; + gap: 4px; + } + } + + &__rsvp { + display: flex; + gap: 12px; + font-size: 0.78rem; + flex-wrap: wrap; + } + + &__badges { + display: flex; + gap: 6px; + } +} + +.meeting-tag { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 20px; + text-transform: uppercase; + letter-spacing: 0.04em; + + &--gbm { + background: #dbeafe; + color: #1d4ed8; + } + + &--officer { + background: #ede9fe; + color: #6d28d9; + } + + &--special { + background: #fef3c7; + color: #92400e; + } +} + +.badge { + font-size: 0.7rem; + font-weight: 600; + padding: 2px 7px; + border-radius: 4px; + + &--completed { + background: #d1fae5; + color: #065f46; + } + + &--minutes { + background: #e0f2fe; + color: #0369a1; + } +} + +.rsvp { + display: inline-flex; + align-items: center; + gap: 4px; + + &.attending { color: #059669; } + &.excused { color: #d97706; } + &.no-response { color: var(--text-muted, #9ca3af); } +} \ No newline at end of file diff --git a/frontend/src/pages/ClubDash/Meetings/Meetings.jsx b/frontend/src/pages/ClubDash/Meetings/Meetings.jsx new file mode 100644 index 00000000..18e5fee6 --- /dev/null +++ b/frontend/src/pages/ClubDash/Meetings/Meetings.jsx @@ -0,0 +1,169 @@ +import React, { useState } from 'react'; +import { useGradient } from '../../../hooks/useGradient'; +import './Meetings.scss'; +import TabbedContainer from '../../../components/TabbedContainer/TabbedContainer'; +import NextMeetingBanner from './NextMeetingBanner/NextMeetingBanner'; +import OverviewTab from './Tabs/OverviewTab'; +import AttendanceTab from './Tabs/AttendanceTab'; +import MinutesTab from './Tabs/MinutesTab'; +import MeetingDetail from './Meeting/Meeting'; + +const defaultUpcomingMeetings = [ + { + id: 'gbm-march', + type: 'gbm', + title: 'General Body Meeting - March', + time: '6:00 PM – 8:00 PM', + location: 'Student Center Room 301', + attending: 42, + excused: 8, + noResponse: 15, + completed: false, + hasMinutes: false, + }, + { + id: 'officer-weekly', + type: 'officer', + title: 'Officer Meeting - Weekly Sync', + time: '2:00 PM – 3:00 PM', + location: 'Zoom', + attending: 7, + excused: 0, + noResponse: 1, + completed: false, + hasMinutes: false, + }, +]; + +const defaultPastMeetings = [ + { + id: 'gbm-february', + type: 'gbm', + title: 'General Body Meeting - February', + time: '6:00 PM – 8:00 PM', + location: 'Student Center Room 301', + attending: 38, + excused: 5, + noResponse: 22, + completed: true, + hasMinutes: true, + }, + { + id: 'special-planning', + type: 'special', + title: 'Special Planning Session', + time: '4:00 PM – 6:00 PM', + location: 'Conference Room A', + attending: 5, + excused: 2, + noResponse: 0, + completed: true, + hasMinutes: true, + }, + { + id: 'officer-mar3', + type: 'officer', + title: 'Officer Meeting - Mar 3', + time: '2:00 PM – 3:00 PM', + location: 'Zoom', + attending: 8, + excused: 0, + noResponse: 0, + completed: true, + hasMinutes: true, + }, +]; + +const defaultAttendanceRecords = [ + { id: 'gbm-march', title: 'GBM - March (in progress)', active: true, attended: 42, excused: 5, unexcused: 3, rate: 84 }, + { id: 'gbm-february', title: 'GBM - February', active: false, attended: 35, excused: 5, unexcused: 17, rate: 61 }, + { id: 'special-planning',title: 'Special Planning Session', active: false, attended: 5, excused: 2, unexcused: 0, rate: 71 }, + { id: 'officer-mar3', title: 'Officer Meeting - Mar 3', active: false, attended: 8, excused: 0, unexcused: 0, rate: 100 }, +]; + +const defaultActiveMeeting = { + tag: 'GBM', + title: 'GBM - March (in progress)', + location: 'Student Center Room 301', + completed: false, +}; + +function Meetings({ + upcomingMeetings = defaultUpcomingMeetings, + pastMeetings = defaultPastMeetings, + attendanceRecords = defaultAttendanceRecords, + activeMeeting = defaultActiveMeeting, +}) { + const { AtlasMain } = useGradient(); + const [selectedMeeting, setSelectedMeeting] = useState(null); + + const handleMeetingClick = (meeting) => setSelectedMeeting(meeting); + const handleBack = () => setSelectedMeeting(null); + + const handleTakeAttendance = () => { + // Find the active meeting object and open it + const active = [...upcomingMeetings, ...pastMeetings].find(m => !m.completed) || defaultActiveMeeting; + setSelectedMeeting(active); + }; + + const tabs = [ + { + id: 'overview', + label: 'Overview', + icon: 'mdi:view-dashboard', + content: , + }, + { + id: 'meeting-minutes', + label: 'Meeting Minutes', + icon: 'mdi:file-document-outline', + content: , + }, + { + id: 'attendance-records', + label: 'Attendance Records', + icon: 'mdi:calendar-check-outline', + content: , + }, + ]; + + // Show detail view when a meeting is selected + if (selectedMeeting) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Meetings

+

Manage GBMs, officer meetings, attendance, and minutes

+ +
+
+
+ +
+ handleMeetingClick(activeMeeting)} + /> + +
+
+ ); +} + +export default Meetings; \ No newline at end of file diff --git a/frontend/src/pages/ClubDash/Meetings/Meetings.scss b/frontend/src/pages/ClubDash/Meetings/Meetings.scss new file mode 100644 index 00000000..af938895 --- /dev/null +++ b/frontend/src/pages/ClubDash/Meetings/Meetings.scss @@ -0,0 +1,397 @@ +.meeting-container { + overflow-y: auto; + padding: 0 40px 40px 40px; + display: flex; + flex-direction: column; + gap: 20px; + + .actions-header { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 40px; + + button { + border-radius: 7px; + white-space: nowrap; + width: 130px; + height: 30px; + font-weight: 600; + font-family: 'Inter'; + padding: 3px 13px; + color: var(--text); + &.plan-meeting-button { + background-color: var(--green); + color: white; + padding-bottom: 5px; + } + } + } + + .next-meeting { + padding: 1.25rem; + border-radius: 12px; + background: rgba(197, 246, 197, 0.16); + border: 1px solid rgba(120, 160, 120, 0.45); + display: flex; + flex-direction: column; + gap: 0.75rem; + + .meeting-status { + display: flex; + align-items: center; + gap: 8px; + + .status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: rgb(30, 110, 44); + } + + .status-label { + color: rgb(30, 110, 44); + font-weight: 600; + font-size: 0.9rem; + } + } + + .meeting-info { + background: var(--lightest, #fff); + border-radius: 10px; + border-left: 4px solid rgb(30, 110, 44); + border-top: 1px solid var(--lighterborder, #e0e0e0); + border-right: 1px solid var(--lighterborder, #e0e0e0); + border-bottom: 1px solid var(--lighterborder, #e0e0e0); + padding: 1rem 1.25rem; + display: flex; + flex-direction: column; + gap: 6px; + + .meeting-info-top { + display: flex; + justify-content: space-between; + align-items: center; + + .meeting-tag { + background: rgba(197, 246, 197, 0.4); + color: rgb(30, 110, 44); + font-size: 0.75rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 6px; + } + + .take-attendance { + color: rgb(30, 110, 44); + font-size: 0.85rem; + cursor: pointer; + &:hover { text-decoration: underline; } + } + } + + .meeting-title { + font-weight: 700; + font-size: 1rem; + color: #1a1a1a; + } + + .meeting-location { + display: flex; + align-items: center; + gap: 4px; + color: #6b7280; + font-size: 0.85rem; + } + } + } + + // ── TabbedContainer full-width tab line ─────────────────── + .tabbed-container--top .tabbed-container__body { + padding: 0; + } + + .tabbed-container__tabs-wrapper { + padding: 0 1.5rem; + } + + .tabbed-container__content { + padding: 1.5rem; + } +} + +// ── Overview Tab ───────────────────────────────────────────── +.overview-content { + display: flex; + flex-direction: column; + gap: 20px; + padding-top: 1.25rem; + + .stat-cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + + .stat-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + padding: 1.25rem; + background: white; + border-radius: 12px; + border: 1px solid var(--lighterborder); + + &--active { + background: rgba(197, 246, 197, 0.2); + border-color: rgba(120, 160, 120, 0.3); + } + + .stat-icon { + font-size: 1.5rem; + color: var(--green); + } + + .stat-number { + font-size: 1.5rem; + font-weight: 700; + color: var(--text); + } + + .stat-label { + font-size: 0.85rem; + color: #6b7280; + } + } + } +} + +// ── Upcoming & Past Meetings ────────────────────────────────── +.upcoming-meetings, +.past-meetings { + display: flex; + flex-direction: column; + gap: 12px; + padding: 1rem 1.5rem 1rem 1.5rem; +} + +.past-meetings { + margin-top: 8px; +} + +.section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 1rem; + font-weight: 700; + color: var(--text); + margin: 0 0 4px 0; + + .section-count { + font-weight: 400; + color: #6b7280; + } +} + +.meeting-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 1.25rem; + background: white; + border-radius: 12px; + border: 1px solid var(--lighterborder); + + &__top { + display: flex; + justify-content: space-between; + align-items: center; + } + + &__title { + font-size: 1rem; + font-weight: 700; + color: var(--text); + } + + &__meta { + display: flex; + align-items: center; + gap: 16px; + font-size: 0.85rem; + color: #6b7280; + + span { + display: flex; + align-items: center; + gap: 4px; + } + } + + &__badges { + display: flex; + gap: 8px; + align-items: center; + } + + &__rsvp { + display: flex; + gap: 16px; + font-size: 0.85rem; + } +} + +// ── Meeting Tags ────────────────────────────────────────────── +.meeting-tag { + font-size: 0.75rem; + font-weight: 600; + padding: 2px 10px; + border-radius: 6px; + display: inline-flex; + align-items: center; + gap: 4px; + + &--gbm { + background: rgba(197, 246, 197, 0.4); + color: rgb(30, 110, 44); + } + &--officer { + background: rgba(197, 220, 255, 0.4); + color: rgb(37, 99, 180); + } + &--special { + background: rgba(255, 240, 180, 0.5); + color: rgb(130, 90, 10); + } +} + +// ── Badges ──────────────────────────────────────────────────── +.badge { + font-size: 0.75rem; + padding: 2px 10px; + border-radius: 6px; + font-weight: 500; + + &--completed { + background: #f3f4f6; + color: #374151; + } + &--minutes { + background: transparent; + color: rgb(30, 110, 44); + cursor: pointer; + &:hover { text-decoration: underline; } + } +} + +// ── RSVP Status Colors ──────────────────────────────────────── +.rsvp { + display: inline-flex; + align-items: center; + gap: 4px; + + &.attending { color: rgb(30, 110, 44); } + &.excused { color: #6b7280; } + &.no-response { color: #9ca3af; } +} + +// ── Attendance Tab ──────────────────────────────────────────── +.attendance-content { + padding-top: 1.25rem; + display: flex; + flex-direction: column; + gap: 16px; + + .attendance-description { + font-size: 0.875rem; + color: #6b7280; + margin: 0; + } + + .attendance-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + + .attendance-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 1.25rem; + background: white; + border-radius: 12px; + border: 1px solid var(--lighterborder); + + &--active { + background: rgba(197, 246, 197, 0.2); + border-color: rgba(120, 160, 120, 0.3); + } + + h3 { + font-size: 1rem; + font-weight: 700; + color: var(--text); + margin: 0; + } + + .in-progress-label { + display: flex; + align-items: center; + gap: 6px; + color: rgb(30, 110, 44); + font-size: 0.85rem; + font-weight: 500; + + .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: rgb(30, 110, 44); + } + } + + .attendance-stats { + display: flex; + flex-direction: column; + gap: 6px; + + .stat { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.875rem; + + &.attended { color: rgb(30, 110, 44); } + &.excused { color: #6b7280; } + &.unexcused { color: #ef4444; } + } + } + + .attendance-bar { + height: 6px; + background: #e5e7eb; + border-radius: 999px; + overflow: hidden; + + &__fill { + height: 100%; + background: rgb(30, 110, 44); + border-radius: 999px; + } + } + + .attendance-rate { + font-size: 0.8rem; + color: #6b7280; + } + + .view-details { + font-size: 0.85rem; + color: rgb(30, 110, 44); + cursor: pointer; + &:hover { text-decoration: underline; } + } + } + } +} \ No newline at end of file diff --git a/frontend/src/pages/ClubDash/Meetings/NextMeetingBanner/NextMeetingBanner.jsx b/frontend/src/pages/ClubDash/Meetings/NextMeetingBanner/NextMeetingBanner.jsx new file mode 100644 index 00000000..3f8808fc --- /dev/null +++ b/frontend/src/pages/ClubDash/Meetings/NextMeetingBanner/NextMeetingBanner.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Icon } from '@iconify-icon/react/dist/iconify.mjs'; +import './NextMeetingBanner.scss'; + +function NextMeetingBanner({ meeting, onTakeAttendance, onClick }) { + if (!meeting) return null; + + const { tag, title, location } = meeting; + + return ( +
e.key === 'Enter' && onClick?.()}> +
+ + Meeting in progress +
+
+
+ {tag} + { e.stopPropagation(); onTakeAttendance?.(); }} + > + Take attendance + +
+
{title}
+
+ + {location} +
+
+
+ ); +} + +export default NextMeetingBanner; \ No newline at end of file diff --git a/frontend/src/pages/ClubDash/Meetings/NextMeetingBanner/NextMeetingBanner.scss b/frontend/src/pages/ClubDash/Meetings/NextMeetingBanner/NextMeetingBanner.scss new file mode 100644 index 00000000..2e8e083d --- /dev/null +++ b/frontend/src/pages/ClubDash/Meetings/NextMeetingBanner/NextMeetingBanner.scss @@ -0,0 +1,93 @@ +.next-meeting { + background: var(--card-bg, #fff); + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 16px; + cursor: pointer; + transition: box-shadow 0.2s ease, border-color 0.2s ease; + + &:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.07); + border-color: var(--accent-color, #6366f1); + } + + &:focus-visible { + outline: 2px solid var(--accent-color, #6366f1); + outline-offset: 2px; + } +} + +.meeting-status { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #22c55e; + display: inline-block; + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.status-label { + font-size: 0.8rem; + color: #22c55e; + font-weight: 500; +} + +.meeting-info { + &-top { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; + } +} + +.meeting-tag { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 20px; + background: #dbeafe; + color: #1d4ed8; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.take-attendance { + font-size: 0.8rem; + color: #6366f1; + font-weight: 500; + cursor: pointer; + + &:hover { text-decoration: underline; } +} + +.meeting-title { + font-weight: 600; + font-size: 0.95rem; + color: var(--text-primary, #111827); + margin-bottom: 4px; +} + +.meeting-location { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.8rem; + color: var(--text-muted, #6b7280); +} \ No newline at end of file diff --git a/frontend/src/pages/ClubDash/Meetings/Tabs/AttendanceTab.jsx b/frontend/src/pages/ClubDash/Meetings/Tabs/AttendanceTab.jsx new file mode 100644 index 00000000..0d9d2d4d --- /dev/null +++ b/frontend/src/pages/ClubDash/Meetings/Tabs/AttendanceTab.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import AttendanceCard from '../AttendanceCard/AttendanceCard'; + +function AttendanceTab({ attendanceRecords = [], onRecordClick }) { + return ( +
+

+ Attendance is tracked per meeting. RSVP Yes → confirm attendance; RSVP No → excused; No response → unexcused. +

+
+ {attendanceRecords.map((record) => ( + onRecordClick?.(record)} + /> + ))} +
+
+ ); +} + +export default AttendanceTab; \ No newline at end of file diff --git a/frontend/src/pages/ClubDash/Meetings/Tabs/MinutesTab.jsx b/frontend/src/pages/ClubDash/Meetings/Tabs/MinutesTab.jsx new file mode 100644 index 00000000..1a5f5f3e --- /dev/null +++ b/frontend/src/pages/ClubDash/Meetings/Tabs/MinutesTab.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Icon } from '@iconify-icon/react/dist/iconify.mjs'; + +function MinutesTab() { + return ( +
+
+ +

Meeting Minutes

+

No minutes have been recorded yet.

+
+
+ ); +} + +export default MinutesTab; \ No newline at end of file diff --git a/frontend/src/pages/ClubDash/Meetings/Tabs/OverviewTab.jsx b/frontend/src/pages/ClubDash/Meetings/Tabs/OverviewTab.jsx new file mode 100644 index 00000000..2f4b3852 --- /dev/null +++ b/frontend/src/pages/ClubDash/Meetings/Tabs/OverviewTab.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { Icon } from '@iconify-icon/react/dist/iconify.mjs'; +import HeaderContainer from '../../../../components/HeaderContainer/HeaderContainer'; +import MeetingCard from '../MeetingCard/MeetingCard'; + +function OverviewTab({ upcomingMeetings = [], pastMeetings = [], onMeetingClick }) { + return ( +
+
+
+ + {upcomingMeetings.length} + Upcoming +
+
+ + 1 + Ongoing +
+
+ + {pastMeetings.length} + Past +
+
+ + {pastMeetings.filter(m => m.hasMinutes).length} + Meeting Minutes +
+
+ + + Plan Meeting} + > +
+

+ + Upcoming ({upcomingMeetings.length}) +

+ {upcomingMeetings.map((meeting) => ( + onMeetingClick?.(meeting)} + /> + ))} +
+ +
+

+ Past ({pastMeetings.length}) +

+ {pastMeetings.map((meeting) => ( + onMeetingClick?.(meeting)} + /> + ))} +
+
+
+ ); +} + +export default OverviewTab; \ No newline at end of file