diff --git a/README.md b/README.md index 5ba35f1..0cc2fa0 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,246 @@ -## AWS Amplify Next.js (App Router) Starter Template +# EduFlow LMS - Professional Learning Management System -This repository provides a starter template for creating applications using Next.js (App Router) and AWS Amplify, emphasizing easy setup for authentication, API, and database capabilities. +A modern, feature-rich Learning Management System built with Next.js, AWS Amplify, and Tailwind CSS. This LMS supports three user roles with comprehensive features for each. -## Overview +## ๐Ÿš€ Features -This template equips you with a foundational Next.js application integrated with AWS Amplify, streamlined for scalability and performance. It is ideal for developers looking to jumpstart their project with pre-configured AWS services like Cognito, AppSync, and DynamoDB. +### ๐Ÿ‘จโ€๐Ÿ’ผ Admin Features +- **User Management**: Create, edit, and manage all users (students, group leaders, admins) +- **Course Management**: Create and manage courses, modules, and lessons +- **Analytics Dashboard**: View comprehensive statistics and reports +- **System Settings**: Configure system-wide settings and preferences +- **Role Management**: Assign and manage user roles and permissions -## Features +### ๐Ÿ‘จโ€๐Ÿซ Group Leader Features +- **Student Management**: Manage students in assigned courses +- **Course Creation**: Create and manage courses and content +- **Assignment Management**: Create, assign, and grade assignments +- **Grade Management**: Track and manage student grades +- **Communication**: Send messages and announcements to students +- **Progress Tracking**: Monitor student progress and performance -- **Authentication**: Setup with Amazon Cognito for secure user authentication. -- **API**: Ready-to-use GraphQL endpoint with AWS AppSync. -- **Database**: Real-time database powered by Amazon DynamoDB. +### ๐Ÿ‘จโ€๐ŸŽ“ Student Features +- **Course Access**: View enrolled courses and content +- **Assignment Submission**: Submit assignments and track status +- **Grade Tracking**: View grades and feedback +- **Progress Monitoring**: Track learning progress and achievements +- **Communication**: Receive messages and announcements +- **Calendar**: View upcoming deadlines and events -## Deploying to AWS +## ๐Ÿ› ๏ธ Technology Stack -For detailed instructions on deploying your application, refer to the [deployment section](https://docs.amplify.aws/nextjs/start/quickstart/nextjs-app-router-client-components/#deploy-a-fullstack-app-to-aws) of our documentation. +- **Frontend**: Next.js 14, React 18, TypeScript +- **Styling**: Tailwind CSS with custom design system +- **Backend**: AWS Amplify Gen2 +- **Authentication**: AWS Cognito with role-based access +- **Database**: AWS DynamoDB with GraphQL API +- **UI Components**: Custom components with Lucide React icons +- **State Management**: React hooks and AWS Amplify data client -## Security +## ๐Ÿ“Š Database Schema -See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. +The LMS includes comprehensive data models: -## License +- **Users**: Profile management with role-based access +- **Courses**: Course creation and management +- **Modules & Lessons**: Structured learning content +- **Assignments**: Assignment creation and submission +- **Grades**: Grade tracking and feedback +- **Enrollments**: Student-course relationships +- **Messages**: Communication system +- **Announcements**: Course-wide notifications +- **Quizzes**: Interactive assessments +- **Calendar Events**: Scheduling and deadlines -This library is licensed under the MIT-0 License. See the LICENSE file. \ No newline at end of file +## ๐ŸŽจ Design System + +- **Modern UI**: Clean, professional design with excellent UX +- **Responsive**: Fully responsive design for all devices +- **Accessibility**: WCAG compliant components +- **Dark Mode**: Support for light/dark themes +- **Custom Components**: Reusable UI components +- **Animations**: Smooth transitions and micro-interactions + +## ๐Ÿš€ Getting Started + +### Prerequisites +- Node.js 18+ +- AWS Account +- AWS CLI configured + +### Installation + +1. **Clone the repository** + ```bash + git clone + cd lms-website + ``` + +2. **Install dependencies** + ```bash + npm install + ``` + +3. **Configure AWS Amplify** + ```bash + npx ampx sandbox + ``` + +4. **Start development server** + ```bash + npm run dev + ``` + +5. **Open your browser** + Navigate to `http://localhost:3000` + +## ๐Ÿ“ฑ Pages & Features + +### Dashboard +- Role-based dashboard with relevant statistics +- Quick actions and recent activity +- Upcoming deadlines and notifications + +### Courses +- Course listing with search and filters +- Course creation and management +- Module and lesson organization + +### Assignments +- Assignment creation and management +- Submission tracking +- Grade management + +### Students (Admin/Group Leader) +- Student listing and management +- Enrollment management +- Progress tracking + +### Grades +- Grade entry and management +- Grade distribution analytics +- Performance tracking + +### Messages +- Internal messaging system +- Announcement management +- Communication tools + +### Calendar +- Event scheduling +- Deadline tracking +- Course timeline + +### Settings +- Profile management +- Security settings +- Notification preferences +- Privacy controls + +## ๐Ÿ” Authentication & Authorization + +- **AWS Cognito Integration**: Secure user authentication +- **Role-Based Access Control**: Three distinct user roles +- **Permission Management**: Granular permissions per role +- **Session Management**: Secure session handling + +## ๐Ÿ“ˆ Performance Features + +- **Optimized Loading**: Lazy loading and code splitting +- **Caching**: Intelligent data caching +- **Responsive Images**: Optimized image delivery +- **Bundle Optimization**: Minimal bundle sizes + +## ๐Ÿงช Testing & Quality + +- **TypeScript**: Full type safety +- **ESLint**: Code quality enforcement +- **Error Handling**: Comprehensive error management +- **Loading States**: User-friendly loading indicators + +## ๐Ÿš€ Deployment + +The application is configured for deployment with AWS Amplify: + +1. **Build the application** + ```bash + npm run build + ``` + +2. **Deploy to AWS Amplify** + ```bash + npx ampx deploy + ``` + +## ๐Ÿ“ User Roles + +### Admin +- Full system access +- User management +- System configuration +- Analytics and reporting + +### Group Leader +- Course management +- Student management +- Assignment creation +- Grade management + +### Student +- Course access +- Assignment submission +- Grade viewing +- Progress tracking + +## ๐Ÿ”ง Configuration + +### Environment Variables +Create a `.env.local` file: +```env +NEXT_PUBLIC_AMPLIFY_PROJECT_NAME=your-project-name +``` + +### AWS Amplify Configuration +The application uses AWS Amplify Gen2 for backend services. Configure your AWS credentials and run: +```bash +npx ampx sandbox +``` + +## ๐Ÿ“š Documentation + +- **API Documentation**: GraphQL schema documentation +- **Component Library**: Storybook documentation +- **User Guides**: Role-specific user guides +- **Developer Guide**: Technical documentation + +## ๐Ÿค Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Submit a pull request + +## ๐Ÿ“„ License + +This project is licensed under the MIT License. + +## ๐Ÿ†˜ Support + +For support and questions: +- Create an issue in the repository +- Check the documentation +- Contact the development team + +## ๐Ÿ”ฎ Future Enhancements + +- **Video Integration**: Video lesson support +- **Mobile App**: React Native mobile application +- **Advanced Analytics**: Enhanced reporting and analytics +- **Integration APIs**: Third-party service integrations +- **AI Features**: Intelligent content recommendations +- **Collaboration Tools**: Real-time collaboration features + +--- + +Built with โค๏ธ using Next.js, AWS Amplify, and modern web technologies. \ No newline at end of file diff --git a/amplify/auth/resource.ts b/amplify/auth/resource.ts index 8049157..26529df 100644 --- a/amplify/auth/resource.ts +++ b/amplify/auth/resource.ts @@ -1,11 +1,23 @@ import { defineAuth } from "@aws-amplify/backend"; /** - * Define and configure your auth resource + * Define and configure your auth resource with user roles * @see https://docs.amplify.aws/gen2/build-a-backend/auth */ export const auth = defineAuth({ loginWith: { email: true, }, + userAttributes: { + email: { + required: true, + }, + givenName: { + required: true, + }, + familyName: { + required: true, + }, + }, + groups: ["admin", "groupLeader", "student"], }); diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index 2ca186a..1e05cbf 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -1,17 +1,350 @@ import { type ClientSchema, a, defineData } from "@aws-amplify/backend"; -/*== STEP 1 =============================================================== -The section below creates a Todo database table with a "content" field. Try -adding a new "isDone" field as a boolean. The authorization rule below -specifies that any user authenticated via an API key can "create", "read", -"update", and "delete" any "Todo" records. -=========================================================================*/ const schema = a.schema({ - Todo: a + // User Profile Model + User: a + .model({ + email: a.string().required(), + firstName: a.string().required(), + lastName: a.string().required(), + role: a.enum(["admin", "groupLeader", "student"]).required(), + avatar: a.string(), + bio: a.string(), + phone: a.string(), + dateOfBirth: a.date(), + address: a.string(), + isActive: a.boolean().default(true), + lastLoginAt: a.datetime(), + createdAt: a.datetime().default(new Date()), + updatedAt: a.datetime().default(new Date()), + + // Relationships + coursesAsInstructor: a.hasMany("Course", "instructorId"), + enrollments: a.hasMany("Enrollment", "studentId"), + assignments: a.hasMany("Assignment", "createdById"), + submissions: a.hasMany("Submission", "studentId"), + grades: a.hasMany("Grade", "studentId"), + announcements: a.hasMany("Announcement", "authorId"), + messages: a.hasMany("Message", "senderId"), + receivedMessages: a.hasMany("Message", "recipientId"), + }) + .authorization((allow) => [ + allow.owner(), + allow.group("admin").to(["create", "read", "update", "delete"]), + allow.group("groupLeader").to(["read"]), + ]), + + // Course Model + Course: a + .model({ + title: a.string().required(), + description: a.text(), + code: a.string().required(), + thumbnail: a.string(), + category: a.string(), + level: a.enum(["beginner", "intermediate", "advanced"]), + duration: a.integer(), // in weeks + credits: a.integer(), + isPublished: a.boolean().default(false), + startDate: a.date(), + endDate: a.date(), + maxStudents: a.integer(), + instructorId: a.id().required(), + createdAt: a.datetime().default(new Date()), + updatedAt: a.datetime().default(new Date()), + + // Relationships + instructor: a.belongsTo("User", "instructorId"), + enrollments: a.hasMany("Enrollment", "courseId"), + modules: a.hasMany("Module", "courseId"), + assignments: a.hasMany("Assignment", "courseId"), + announcements: a.hasMany("Announcement", "courseId"), + }) + .authorization((allow) => [ + allow.group("admin").to(["create", "read", "update", "delete"]), + allow.group("groupLeader").to(["create", "read", "update"]), + allow.group("student").to(["read"]), + ]), + + // Module Model + Module: a + .model({ + title: a.string().required(), + description: a.text(), + order: a.integer().required(), + courseId: a.id().required(), + isPublished: a.boolean().default(false), + createdAt: a.datetime().default(new Date()), + updatedAt: a.datetime().default(new Date()), + + // Relationships + course: a.belongsTo("Course", "courseId"), + lessons: a.hasMany("Lesson", "moduleId"), + }) + .authorization((allow) => [ + allow.group("admin").to(["create", "read", "update", "delete"]), + allow.group("groupLeader").to(["create", "read", "update"]), + allow.group("student").to(["read"]), + ]), + + // Lesson Model + Lesson: a + .model({ + title: a.string().required(), + content: a.text(), + type: a.enum(["video", "text", "quiz", "assignment"]), + duration: a.integer(), // in minutes + order: a.integer().required(), + moduleId: a.id().required(), + videoUrl: a.string(), + attachments: a.string().array(), + isPublished: a.boolean().default(false), + createdAt: a.datetime().default(new Date()), + updatedAt: a.datetime().default(new Date()), + + // Relationships + module: a.belongsTo("Module", "moduleId"), + quiz: a.hasOne("Quiz", "lessonId"), + }) + .authorization((allow) => [ + allow.group("admin").to(["create", "read", "update", "delete"]), + allow.group("groupLeader").to(["create", "read", "update"]), + allow.group("student").to(["read"]), + ]), + + // Quiz Model + Quiz: a + .model({ + title: a.string().required(), + description: a.text(), + timeLimit: a.integer(), // in minutes + attempts: a.integer().default(1), + passingScore: a.float(), + lessonId: a.id().required(), + isPublished: a.boolean().default(false), + createdAt: a.datetime().default(new Date()), + updatedAt: a.datetime().default(new Date()), + + // Relationships + lesson: a.belongsTo("Lesson", "lessonId"), + questions: a.hasMany("Question", "quizId"), + attempts: a.hasMany("QuizAttempt", "quizId"), + }) + .authorization((allow) => [ + allow.group("admin").to(["create", "read", "update", "delete"]), + allow.group("groupLeader").to(["create", "read", "update"]), + allow.group("student").to(["read"]), + ]), + + // Question Model + Question: a + .model({ + question: a.string().required(), + type: a.enum(["multipleChoice", "trueFalse", "shortAnswer", "essay"]), + options: a.string().array(), + correctAnswer: a.string(), + explanation: a.text(), + points: a.float().default(1), + order: a.integer().required(), + quizId: a.id().required(), + createdAt: a.datetime().default(new Date()), + updatedAt: a.datetime().default(new Date()), + + // Relationships + quiz: a.belongsTo("Quiz", "quizId"), + answers: a.hasMany("Answer", "questionId"), + }) + .authorization((allow) => [ + allow.group("admin").to(["create", "read", "update", "delete"]), + allow.group("groupLeader").to(["create", "read", "update"]), + allow.group("student").to(["read"]), + ]), + + // Assignment Model + Assignment: a + .model({ + title: a.string().required(), + description: a.text(), + instructions: a.text(), + dueDate: a.datetime(), + points: a.float().required(), + type: a.enum(["essay", "project", "presentation", "other"]), + attachments: a.string().array(), + courseId: a.id().required(), + createdById: a.id().required(), + isPublished: a.boolean().default(false), + createdAt: a.datetime().default(new Date()), + updatedAt: a.datetime().default(new Date()), + + // Relationships + course: a.belongsTo("Course", "courseId"), + createdBy: a.belongsTo("User", "createdById"), + submissions: a.hasMany("Submission", "assignmentId"), + }) + .authorization((allow) => [ + allow.group("admin").to(["create", "read", "update", "delete"]), + allow.group("groupLeader").to(["create", "read", "update"]), + allow.group("student").to(["read"]), + ]), + + // Enrollment Model + Enrollment: a + .model({ + studentId: a.id().required(), + courseId: a.id().required(), + enrolledAt: a.datetime().default(new Date()), + status: a.enum(["active", "completed", "dropped", "suspended"]).default("active"), + progress: a.float().default(0), + grade: a.string(), + + // Relationships + student: a.belongsTo("User", "studentId"), + course: a.belongsTo("Course", "courseId"), + }) + .authorization((allow) => [ + allow.owner(), + allow.group("admin").to(["create", "read", "update", "delete"]), + allow.group("groupLeader").to(["create", "read", "update"]), + ]), + + // Submission Model + Submission: a + .model({ + assignmentId: a.id().required(), + studentId: a.id().required(), + content: a.text(), + attachments: a.string().array(), + submittedAt: a.datetime().default(new Date()), + status: a.enum(["draft", "submitted", "graded", "returned"]).default("draft"), + grade: a.float(), + feedback: a.text(), + gradedAt: a.datetime(), + gradedById: a.id(), + + // Relationships + assignment: a.belongsTo("Assignment", "assignmentId"), + student: a.belongsTo("User", "studentId"), + gradedBy: a.belongsTo("User", "gradedById"), + }) + .authorization((allow) => [ + allow.owner(), + allow.group("admin").to(["create", "read", "update", "delete"]), + allow.group("groupLeader").to(["create", "read", "update"]), + ]), + + // Grade Model + Grade: a + .model({ + studentId: a.id().required(), + courseId: a.id().required(), + assignmentId: a.id(), + quizId: a.id(), + points: a.float().required(), + maxPoints: a.float().required(), + percentage: a.float(), + letterGrade: a.string(), + feedback: a.text(), + gradedAt: a.datetime().default(new Date()), + gradedById: a.id().required(), + + // Relationships + student: a.belongsTo("User", "studentId"), + course: a.belongsTo("Course", "courseId"), + assignment: a.belongsTo("Assignment", "assignmentId"), + quiz: a.belongsTo("Quiz", "quizId"), + gradedBy: a.belongsTo("User", "gradedById"), + }) + .authorization((allow) => [ + allow.owner(), + allow.group("admin").to(["create", "read", "update", "delete"]), + allow.group("groupLeader").to(["create", "read", "update"]), + ]), + + // Announcement Model + Announcement: a + .model({ + title: a.string().required(), + content: a.text().required(), + courseId: a.id(), + authorId: a.id().required(), + priority: a.enum(["low", "medium", "high"]).default("medium"), + isPublished: a.boolean().default(true), + publishedAt: a.datetime().default(new Date()), + expiresAt: a.datetime(), + createdAt: a.datetime().default(new Date()), + updatedAt: a.datetime().default(new Date()), + + // Relationships + course: a.belongsTo("Course", "courseId"), + author: a.belongsTo("User", "authorId"), + }) + .authorization((allow) => [ + allow.group("admin").to(["create", "read", "update", "delete"]), + allow.group("groupLeader").to(["create", "read", "update"]), + allow.group("student").to(["read"]), + ]), + + // Message Model + Message: a + .model({ + senderId: a.id().required(), + recipientId: a.id().required(), + subject: a.string().required(), + content: a.text().required(), + isRead: a.boolean().default(false), + readAt: a.datetime(), + sentAt: a.datetime().default(new Date()), + + // Relationships + sender: a.belongsTo("User", "senderId"), + recipient: a.belongsTo("User", "recipientId"), + }) + .authorization((allow) => [ + allow.owner(), + allow.group("admin").to(["create", "read", "update", "delete"]), + ]), + + // Quiz Attempt Model + QuizAttempt: a + .model({ + quizId: a.id().required(), + studentId: a.id().required(), + startedAt: a.datetime().default(new Date()), + completedAt: a.datetime(), + score: a.float(), + percentage: a.float(), + timeSpent: a.integer(), // in minutes + answers: a.string().array(), + + // Relationships + quiz: a.belongsTo("Quiz", "quizId"), + student: a.belongsTo("User", "studentId"), + }) + .authorization((allow) => [ + allow.owner(), + allow.group("admin").to(["create", "read", "update", "delete"]), + allow.group("groupLeader").to(["create", "read", "update"]), + ]), + + // Answer Model + Answer: a .model({ - content: a.string(), + questionId: a.id().required(), + studentId: a.id().required(), + answer: a.string(), + isCorrect: a.boolean(), + points: a.float(), + answeredAt: a.datetime().default(new Date()), + + // Relationships + question: a.belongsTo("Question", "questionId"), + student: a.belongsTo("User", "studentId"), }) - .authorization((allow) => [allow.publicApiKey()]), + .authorization((allow) => [ + allow.owner(), + allow.group("admin").to(["create", "read", "update", "delete"]), + allow.group("groupLeader").to(["create", "read", "update"]), + ]), }); export type Schema = ClientSchema; @@ -19,7 +352,7 @@ export type Schema = ClientSchema; export const data = defineData({ schema, authorizationModes: { - defaultAuthorizationMode: "apiKey", + defaultAuthorizationMode: "userPool", apiKeyAuthorizationMode: { expiresInDays: 30, }, diff --git a/app/assignments/page.tsx b/app/assignments/page.tsx new file mode 100644 index 0000000..a44e828 --- /dev/null +++ b/app/assignments/page.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { generateClient } from "aws-amplify/data"; +import type { Schema } from "@/amplify/data/resource"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; +import { + FileText, + Calendar, + Clock, + Plus, + Search, + Filter, + Eye, + Edit, + Trash2, + CheckCircle, + AlertCircle +} from "lucide-react"; +import { formatDate, formatDateTime } from "@/lib/utils"; +import toast from "react-hot-toast"; + +const client = generateClient(); + +export default function AssignmentsPage() { + const [assignments, setAssignments] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [filterType, setFilterType] = useState("all"); + const [filterStatus, setFilterStatus] = useState("all"); + + useEffect(() => { + fetchAssignments(); + }, []); + + const fetchAssignments = async () => { + try { + setLoading(true); + const { data } = await client.models.Assignment.list(); + setAssignments(data); + } catch (error) { + console.error("Error fetching assignments:", error); + toast.error("Failed to load assignments"); + } finally { + setLoading(false); + } + }; + + const filteredAssignments = assignments.filter(assignment => { + const matchesSearch = assignment.title.toLowerCase().includes(searchTerm.toLowerCase()) || + assignment.course?.title.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesType = filterType === "all" || assignment.type === filterType; + + // Status filter logic + let matchesStatus = true; + if (filterStatus !== "all") { + const now = new Date(); + const dueDate = assignment.dueDate ? new Date(assignment.dueDate) : null; + + if (filterStatus === "upcoming" && dueDate) { + matchesStatus = dueDate > now; + } else if (filterStatus === "overdue" && dueDate) { + matchesStatus = dueDate < now; + } else if (filterStatus === "no-deadline") { + matchesStatus = !dueDate; + } + } + + return matchesSearch && matchesType && matchesStatus; + }); + + const getTypeColor = (type: string) => { + switch (type) { + case "essay": return "bg-blue-100 text-blue-800"; + case "project": return "bg-green-100 text-green-800"; + case "presentation": return "bg-purple-100 text-purple-800"; + case "other": return "bg-gray-100 text-gray-800"; + default: return "bg-gray-100 text-gray-800"; + } + }; + + const getStatusIcon = (assignment: Schema["Assignment"]["type"]) => { + if (!assignment.dueDate) return ; + + const now = new Date(); + const dueDate = new Date(assignment.dueDate); + + if (dueDate < now) { + return ; + } else { + return ; + } + }; + + const getStatusText = (assignment: Schema["Assignment"]["type"]) => { + if (!assignment.dueDate) return "No deadline"; + + const now = new Date(); + const dueDate = new Date(assignment.dueDate); + + if (dueDate < now) { + return "Overdue"; + } else { + const diffTime = dueDate.getTime() - now.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + return `Due in ${diffDays} day${diffDays !== 1 ? 's' : ''}`; + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Assignments

+

Manage and view all assignments

+
+ +
+ + {/* Filters */} + + +
+
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+
+ + + +
+
+
+
+ + {/* Assignments List */} +
+ {filteredAssignments.map((assignment) => ( + + +
+
+
+
+ +
+
+

{assignment.title}

+

{assignment.course?.title}

+
+ + {assignment.type} + +
+ +

+ {assignment.description || "No description available"} +

+ +
+
+ + + {assignment.dueDate ? formatDateTime(assignment.dueDate) : "No deadline"} + +
+
+ {getStatusIcon(assignment)} + {getStatusText(assignment)} +
+
+ {assignment.points} points +
+
+
+ +
+ + + +
+
+
+
+ ))} +
+ + {filteredAssignments.length === 0 && ( + + + +

No assignments found

+

+ {searchTerm || filterType !== "all" || filterStatus !== "all" + ? "Try adjusting your search or filter criteria" + : "Get started by creating your first assignment" + } +

+ +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/calendar/page.tsx b/app/calendar/page.tsx new file mode 100644 index 0000000..0ec51f9 --- /dev/null +++ b/app/calendar/page.tsx @@ -0,0 +1,428 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { generateClient } from "aws-amplify/data"; +import type { Schema } from "@/amplify/data/resource"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; +import { + Calendar, + ChevronLeft, + ChevronRight, + Plus, + Clock, + Users, + BookOpen, + FileText +} from "lucide-react"; +import { formatDate } from "@/lib/utils"; +import toast from "react-hot-toast"; + +const client = generateClient(); + +interface CalendarEvent { + id: string; + title: string; + date: Date; + type: "assignment" | "course" | "announcement"; + description?: string; + dueDate?: Date; + course?: Schema["Course"]["type"]; +} + +export default function CalendarPage() { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [currentDate, setCurrentDate] = useState(new Date()); + const [selectedDate, setSelectedDate] = useState(null); + + useEffect(() => { + fetchEvents(); + }, []); + + const fetchEvents = async () => { + try { + setLoading(true); + + // Fetch assignments + const { data: assignments } = await client.models.Assignment.list(); + + // Fetch courses + const { data: courses } = await client.models.Course.list(); + + // Fetch announcements + const { data: announcements } = await client.models.Announcement.list(); + + const calendarEvents: CalendarEvent[] = []; + + // Add assignment due dates + assignments.forEach(assignment => { + if (assignment.dueDate) { + calendarEvents.push({ + id: `assignment-${assignment.id}`, + title: assignment.title, + date: new Date(assignment.dueDate), + type: "assignment", + description: assignment.description, + dueDate: new Date(assignment.dueDate), + course: assignment.course, + }); + } + }); + + // Add course start dates + courses.forEach(course => { + if (course.startDate) { + calendarEvents.push({ + id: `course-start-${course.id}`, + title: `${course.title} - Start`, + date: new Date(course.startDate), + type: "course", + description: course.description, + course: course, + }); + } + if (course.endDate) { + calendarEvents.push({ + id: `course-end-${course.id}`, + title: `${course.title} - End`, + date: new Date(course.endDate), + type: "course", + description: course.description, + course: course, + }); + } + }); + + // Add announcements + announcements.forEach(announcement => { + if (announcement.publishedAt) { + calendarEvents.push({ + id: `announcement-${announcement.id}`, + title: announcement.title, + date: new Date(announcement.publishedAt), + type: "announcement", + description: announcement.content, + }); + } + }); + + setEvents(calendarEvents); + } catch (error) { + console.error("Error fetching events:", error); + toast.error("Failed to load calendar events"); + } finally { + setLoading(false); + } + }; + + const getDaysInMonth = (date: Date) => { + const year = date.getFullYear(); + const month = date.getMonth(); + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + const daysInMonth = lastDay.getDate(); + const startingDayOfWeek = firstDay.getDay(); + + const days = []; + + // Add empty cells for days before the first day of the month + for (let i = 0; i < startingDayOfWeek; i++) { + days.push(null); + } + + // Add days of the month + for (let day = 1; day <= daysInMonth; day++) { + days.push(new Date(year, month, day)); + } + + return days; + }; + + const getEventsForDate = (date: Date) => { + return events.filter(event => + event.date.toDateString() === date.toDateString() + ); + }; + + const getEventIcon = (type: string) => { + switch (type) { + case "assignment": return ; + case "course": return ; + case "announcement": return ; + default: return ; + } + }; + + const getEventColor = (type: string) => { + switch (type) { + case "assignment": return "bg-red-100 text-red-800"; + case "course": return "bg-blue-100 text-blue-800"; + case "announcement": return "bg-green-100 text-green-800"; + default: return "bg-gray-100 text-gray-800"; + } + }; + + const navigateMonth = (direction: "prev" | "next") => { + const newDate = new Date(currentDate); + if (direction === "prev") { + newDate.setMonth(newDate.getMonth() - 1); + } else { + newDate.setMonth(newDate.getMonth() + 1); + } + setCurrentDate(newDate); + }; + + const monthNames = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" + ]; + + const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Calendar

+

View upcoming events and deadlines

+
+ +
+ + {/* Calendar Stats */} +
+ + +
+
+ +
+
+

Assignments

+

+ {events.filter(e => e.type === "assignment").length} +

+
+
+
+
+ + + +
+
+ +
+
+

Courses

+

+ {events.filter(e => e.type === "course").length} +

+
+
+
+
+ + + +
+
+ +
+
+

Announcements

+

+ {events.filter(e => e.type === "announcement").length} +

+
+
+
+
+ + + +
+
+ +
+
+

Total Events

+

{events.length}

+
+
+
+
+
+ +
+ {/* Calendar */} +
+ + +
+ + {monthNames[currentDate.getMonth()]} {currentDate.getFullYear()} + +
+ + +
+
+
+ +
+ {dayNames.map(day => ( +
+ {day} +
+ ))} +
+
+ {getDaysInMonth(currentDate).map((day, index) => { + if (!day) { + return
; + } + + const dayEvents = getEventsForDate(day); + const isToday = day.toDateString() === new Date().toDateString(); + const isSelected = selectedDate?.toDateString() === day.toDateString(); + + return ( +
setSelectedDate(day)} + > +
+ {day.getDate()} +
+
+ {dayEvents.slice(0, 2).map(event => ( +
+ {getEventIcon(event.type)} + {event.title} +
+ ))} + {dayEvents.length > 2 && ( +
+ +{dayEvents.length - 2} more +
+ )} +
+
+ ); + })} +
+
+
+
+ + {/* Event Details */} +
+ + + + {selectedDate ? formatDate(selectedDate) : "Select a date"} + + + + {selectedDate ? ( +
+ {getEventsForDate(selectedDate).length > 0 ? ( + getEventsForDate(selectedDate).map(event => ( +
+
+
+ {getEventIcon(event.type)} +
+
+

+ {event.title} +

+

+ {event.type} +

+ {event.description && ( +

+ {event.description} +

+ )} + {event.dueDate && ( +
+ + Due: {formatDate(event.dueDate)} +
+ )} +
+
+
+ )) + ) : ( +

No events on this date

+ )} +
+ ) : ( +

Click on a date to view events

+ )} +
+
+ + {/* Upcoming Events */} + + + Upcoming Events + + +
+ {events + .filter(event => event.date >= new Date()) + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .slice(0, 5) + .map(event => ( +
+
+ {getEventIcon(event.type)} +
+
+

+ {event.title} +

+

+ {formatDate(event.date)} +

+
+
+ ))} +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/courses/page.tsx b/app/courses/page.tsx new file mode 100644 index 0000000..bf710c6 --- /dev/null +++ b/app/courses/page.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { generateClient } from "aws-amplify/data"; +import type { Schema } from "@/amplify/data/resource"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; +import { + BookOpen, + Users, + Calendar, + Clock, + Plus, + Search, + Filter, + Eye, + Edit, + Trash2 +} from "lucide-react"; +import { formatDate } from "@/lib/utils"; +import toast from "react-hot-toast"; + +const client = generateClient(); + +export default function CoursesPage() { + const [courses, setCourses] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [filterLevel, setFilterLevel] = useState("all"); + + useEffect(() => { + fetchCourses(); + }, []); + + const fetchCourses = async () => { + try { + setLoading(true); + const { data } = await client.models.Course.list(); + setCourses(data); + } catch (error) { + console.error("Error fetching courses:", error); + toast.error("Failed to load courses"); + } finally { + setLoading(false); + } + }; + + const filteredCourses = courses.filter(course => { + const matchesSearch = course.title.toLowerCase().includes(searchTerm.toLowerCase()) || + course.code.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesLevel = filterLevel === "all" || course.level === filterLevel; + return matchesSearch && matchesLevel; + }); + + const getLevelColor = (level: string) => { + switch (level) { + case "beginner": return "bg-green-100 text-green-800"; + case "intermediate": return "bg-yellow-100 text-yellow-800"; + case "advanced": return "bg-red-100 text-red-800"; + default: return "bg-gray-100 text-gray-800"; + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Courses

+

Manage and view all courses

+
+ +
+ + {/* Filters */} + + +
+
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+
+ + +
+
+
+
+ + {/* Courses Grid */} +
+ {filteredCourses.map((course) => ( + + +
+
+
+ +
+
+ {course.title} +

{course.code}

+
+
+ + {course.level} + +
+
+ +

+ {course.description || "No description available"} +

+ +
+
+ + Instructor: {course.instructor?.firstName} {course.instructor?.lastName} +
+ {course.startDate && ( +
+ + Starts: {formatDate(course.startDate)} +
+ )} + {course.duration && ( +
+ + {course.duration} weeks +
+ )} +
+ +
+
+ + +
+ +
+
+
+ ))} +
+ + {filteredCourses.length === 0 && ( + + + +

No courses found

+

+ {searchTerm || filterLevel !== "all" + ? "Try adjusting your search or filter criteria" + : "Get started by creating your first course" + } +

+ +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index b5bc407..b2532ca 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,62 +1,101 @@ -:root { - --max-width: 1100px; - --border-radius: 12px; - --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', - 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', - 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; +@tailwind base; +@tailwind components; +@tailwind utilities; - --foreground-rgb: 0, 0, 0; - --background-start-rgb: #CBBEFF; - --background-end-rgb: 255, 255, 255; - - --callout-rgb: 238, 240, 241; - --callout-border-rgb: 172, 175, 176; - --card-rgb: #6649AE; - --card-border-rgb: 131, 134, 135; -} - -@media (prefers-color-scheme: dark) { +@layer base { :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; - - --callout-rgb: 20, 20, 20; - --callout-border-rgb: 108, 108, 108; - --card-rgb: 100, 100, 100; - --card-border-rgb: 200, 200, 200; + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96%; + --secondary-foreground: 222.2 84% 4.9%; + --muted: 210 40% 96%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96%; + --accent-foreground: 222.2 84% 4.9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; } -} -* { - box-sizing: border-box; - padding: 0; - margin: 0; -} - -html, -body { - max-width: 100vw; - overflow-x: hidden; -} - -body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 84% 4.9%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 94.1%; + } } -a { - color: inherit; - text-decoration: none; +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } } -@media (prefers-color-scheme: dark) { - html { - color-scheme: dark; +@layer components { + .btn { + @apply inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background; } -} + + .btn-primary { + @apply bg-primary text-primary-foreground hover:bg-primary/90; + } + + .btn-secondary { + @apply bg-secondary text-secondary-foreground hover:bg-secondary/80; + } + + .btn-outline { + @apply border border-input hover:bg-accent hover:text-accent-foreground; + } + + .btn-sm { + @apply h-9 px-3; + } + + .btn-md { + @apply h-10 py-2 px-4; + } + + .btn-lg { + @apply h-11 px-8; + } + + .card { + @apply rounded-lg border bg-card text-card-foreground shadow-soft; + } + + .input { + @apply flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50; + } + + .label { + @apply text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70; + } +} \ No newline at end of file diff --git a/app/grades/page.tsx b/app/grades/page.tsx new file mode 100644 index 0000000..8ee98d9 --- /dev/null +++ b/app/grades/page.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { generateClient } from "aws-amplify/data"; +import type { Schema } from "@/amplify/data/resource"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; +import { + Award, + Search, + Filter, + Download, + TrendingUp, + TrendingDown, + Minus, + Plus +} from "lucide-react"; +import { formatDate, calculateGrade, getGradeColor } from "@/lib/utils"; +import toast from "react-hot-toast"; + +const client = generateClient(); + +export default function GradesPage() { + const [grades, setGrades] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [filterCourse, setFilterCourse] = useState("all"); + + useEffect(() => { + fetchGrades(); + }, []); + + const fetchGrades = async () => { + try { + setLoading(true); + const { data } = await client.models.Grade.list(); + setGrades(data); + } catch (error) { + console.error("Error fetching grades:", error); + toast.error("Failed to load grades"); + } finally { + setLoading(false); + } + }; + + const filteredGrades = grades.filter(grade => { + const matchesSearch = + grade.student?.firstName.toLowerCase().includes(searchTerm.toLowerCase()) || + grade.student?.lastName.toLowerCase().includes(searchTerm.toLowerCase()) || + grade.course?.title.toLowerCase().includes(searchTerm.toLowerCase()) || + grade.assignment?.title.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesCourse = filterCourse === "all" || grade.courseId === filterCourse; + + return matchesSearch && matchesCourse; + }); + + // Calculate statistics + const totalGrades = filteredGrades.length; + const averageGrade = totalGrades > 0 + ? filteredGrades.reduce((sum, grade) => sum + (grade.percentage || 0), 0) / totalGrades + : 0; + + const gradeDistribution = { + A: filteredGrades.filter(g => g.letterGrade === "A").length, + B: filteredGrades.filter(g => g.letterGrade === "B").length, + C: filteredGrades.filter(g => g.letterGrade === "C").length, + D: filteredGrades.filter(g => g.letterGrade === "D").length, + F: filteredGrades.filter(g => g.letterGrade === "F").length, + }; + + const getGradeTrend = (grade: Schema["Grade"]["type"]) => { + // Mock trend calculation - in real app, compare with previous grades + const percentage = grade.percentage || 0; + if (percentage >= 85) return ; + if (percentage >= 70) return ; + return ; + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Grades

+

View and manage student grades

+
+
+ + +
+
+ + {/* Stats */} +
+ + +
+
+ +
+
+

Total Grades

+

{totalGrades}

+
+
+
+
+ + + +
+
+ +
+
+

Average Grade

+

+ {averageGrade.toFixed(1)}% +

+
+
+
+
+ + + +
+
+ +
+
+

Passing Rate

+

+ {totalGrades > 0 + ? ((gradeDistribution.A + gradeDistribution.B + gradeDistribution.C) / totalGrades * 100).toFixed(1) + : 0}% +

+
+
+
+
+ + + +
+
+ +
+
+

Top Grade

+

+ {totalGrades > 0 + ? Math.max(...filteredGrades.map(g => g.percentage || 0)).toFixed(1) + : 0}% +

+
+
+
+
+
+ + {/* Grade Distribution */} + + + Grade Distribution + + +
+ {Object.entries(gradeDistribution).map(([grade, count]) => ( +
+
+ {grade} +
+
{count}
+
+ {totalGrades > 0 ? ((count / totalGrades) * 100).toFixed(1) : 0}% +
+
+ ))} +
+
+
+ + {/* Filters */} + + +
+
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+
+ + +
+
+
+
+ + {/* Grades Table */} + + + Grade List + + +
+ + + + + + + + + + + + + {filteredGrades.map((grade) => ( + + + + + + + + + ))} + +
+ Student + + Assignment/Course + + Score + + Grade + + Trend + + Date +
+
+ {grade.student?.firstName} {grade.student?.lastName} +
+
+ {grade.student?.email} +
+
+
+ {grade.assignment?.title || grade.course?.title || "N/A"} +
+
+ {grade.course?.title} +
+
+
+ {grade.points} / {grade.maxPoints} +
+
+
+ + {grade.letterGrade || calculateGrade(grade.points, grade.maxPoints)} + + + ({grade.percentage?.toFixed(1)}%) + +
+
+ {getGradeTrend(grade)} + + {formatDate(grade.gradedAt)} +
+
+
+
+ + {filteredGrades.length === 0 && ( + + + +

No grades found

+

+ {searchTerm || filterCourse !== "all" + ? "Try adjusting your search or filter criteria" + : "No grades have been recorded yet" + } +

+ +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 457050c..54acd3a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,12 +1,14 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; -import "./app.css"; +import "./globals.css"; +import { AmplifyProvider } from "@/components/AmplifyProvider"; +import { Toaster } from "react-hot-toast"; const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "EduFlow LMS", + description: "Professional Learning Management System with Advanced Features", }; export default function RootLayout({ @@ -16,7 +18,21 @@ export default function RootLayout({ }) { return ( - {children} + + + {children} + + + ); } diff --git a/app/messages/page.tsx b/app/messages/page.tsx new file mode 100644 index 0000000..edaedd8 --- /dev/null +++ b/app/messages/page.tsx @@ -0,0 +1,376 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { generateClient } from "aws-amplify/data"; +import type { Schema } from "@/amplify/data/resource"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; +import { + MessageSquare, + Search, + Plus, + Send, + Reply, + Trash2, + Star, + StarOff, + Filter, + Mail, + MailOpen, + User +} from "lucide-react"; +import { formatDateTime, formatRelativeTime } from "@/lib/utils"; +import toast from "react-hot-toast"; + +const client = generateClient(); + +export default function MessagesPage() { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [filterStatus, setFilterStatus] = useState("all"); + const [selectedMessage, setSelectedMessage] = useState(null); + const [composeOpen, setComposeOpen] = useState(false); + + useEffect(() => { + fetchMessages(); + }, []); + + const fetchMessages = async () => { + try { + setLoading(true); + const { data } = await client.models.Message.list(); + setMessages(data); + } catch (error) { + console.error("Error fetching messages:", error); + toast.error("Failed to load messages"); + } finally { + setLoading(false); + } + }; + + const filteredMessages = messages.filter(message => { + const matchesSearch = + message.subject.toLowerCase().includes(searchTerm.toLowerCase()) || + message.content.toLowerCase().includes(searchTerm.toLowerCase()) || + message.sender?.firstName.toLowerCase().includes(searchTerm.toLowerCase()) || + message.sender?.lastName.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesStatus = filterStatus === "all" || + (filterStatus === "unread" && !message.isRead) || + (filterStatus === "read" && message.isRead); + + return matchesSearch && matchesStatus; + }); + + const unreadCount = messages.filter(m => !m.isRead).length; + + const markAsRead = async (messageId: string) => { + try { + await client.models.Message.update({ + id: messageId, + isRead: true, + readAt: new Date().toISOString(), + }); + fetchMessages(); + } catch (error) { + console.error("Error marking message as read:", error); + toast.error("Failed to mark message as read"); + } + }; + + const deleteMessage = async (messageId: string) => { + try { + await client.models.Message.delete({ id: messageId }); + fetchMessages(); + if (selectedMessage?.id === messageId) { + setSelectedMessage(null); + } + toast.success("Message deleted"); + } catch (error) { + console.error("Error deleting message:", error); + toast.error("Failed to delete message"); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Messages

+

Communicate with students and instructors

+
+ +
+ + {/* Stats */} +
+ + +
+
+ +
+
+

Total Messages

+

{messages.length}

+
+
+
+
+ + + +
+
+ +
+
+

Unread

+

{unreadCount}

+
+
+
+
+ + + +
+
+ +
+
+

Read

+

{messages.length - unreadCount}

+
+
+
+
+
+ + {/* Filters */} + + +
+
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+
+ + +
+
+
+
+ +
+ {/* Messages List */} +
+ + + Inbox + + +
+ {filteredMessages.map((message) => ( +
{ + setSelectedMessage(message); + if (!message.isRead) { + markAsRead(message.id); + } + }} + > +
+
+
+ +
+
+
+
+

+ {message.sender?.firstName} {message.sender?.lastName} +

+

+ {formatRelativeTime(message.sentAt)} +

+
+

+ {message.subject} +

+

+ {message.content} +

+
+ {!message.isRead && ( +
+
+
+ )} +
+
+ ))} +
+
+
+
+ + {/* Message Detail */} +
+ {selectedMessage ? ( + + +
+
+ {selectedMessage.subject} +
+ From: {selectedMessage.sender?.firstName} {selectedMessage.sender?.lastName} + โ€ข + {formatDateTime(selectedMessage.sentAt)} +
+
+
+ + +
+
+
+ +
+

+ {selectedMessage.content} +

+
+
+
+ ) : ( + + + +

Select a message

+

Choose a message from the list to view its content

+
+
+ )} +
+
+ + {/* Compose Modal */} + {composeOpen && ( +
+ + + Compose Message + + +
+
+ + +
+
+ + +
+
+ +