diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..16607d6 --- /dev/null +++ b/.env.development @@ -0,0 +1,40 @@ +VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiaWF0IjoxNzcxODE3MjE5LCJpc3MiOiJzdXBhYmFzZSIsInN1YiI6IjAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMCIsImV4cCI6MzM0ODYxNzIxOSwicm9sZSI6ImFub24ifQ.JbmQOTOBAzpBJ9JttOrGlo_JTXDXhCjYMjKiFvRkaNQ +PORT_FRONTEND=5173 +PORT_FRONTEND_HMR=3002 +PORT_API=8000 +PORT_SUPABASE=8000 + +HOST_FRONTEND=192.168.0.94:5173 +VITE_PORT_FRONTEND=5173 +VITE_PORT_FRONTEND_HMR=5173 + +VITE_APP_NAME="Classroom Copilot" +VITE_SUPER_ADMIN_EMAIL=admin@classroomcopilot.ai +VITE_DEV=true +VITE_FRONTEND_SITE_URL=http://192.168.0.94:5173 +VITE_APP_HMR_URL=http://192.168.0.94:5173 + +# Supabase is on external container - use its IP +VITE_SUPABASE_URL=http://192.168.0.155:8000 + +# API should use localhost for local development +VITE_API_URL=http://192.168.0.94:8000 +VITE_API_BASE=http://192.168.0.94:8000 + +# Neo4j +VITE_NEO4J_URL=bolt://192.168.0.208:7687 +VITE_NEO4J_USER=neo4j +VITE_NEO4J_PASSWORD=kevlarai + +# LLM +VITE_LLM_PROVIDER=ollama +VITE_OLLAMA_API_URL=https://ollama.kevlarai.com + +# Microsoft +VITE_MICROSOFT_CLIENT_ID=dummy_client_id +VITE_MICROSOFT_CLIENT_SECRET_DESC="Microsoft OAuth client secret" +VITE_MICROSOFT_CLIENT_SECRET_ID=dummy_secret_id +VITE_MICROSOFT_CLIENT_SECRET=dummy_secret +VITE_MICROSOFT_TENANT_ID=common + +VITE_SEARCH_URL=http://localhost:8888 diff --git a/src/components/common/Modal.tsx b/src/components/common/Modal.tsx new file mode 100644 index 0000000..015cd2d --- /dev/null +++ b/src/components/common/Modal.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { X } from 'lucide-react'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + maxWidth?: string; +} + +const Modal: React.FC = ({ isOpen, onClose, title, children, maxWidth = 'max-w-lg' }) => { + if (!isOpen) return null; + + return ( +
+
+ {/* Backdrop */} +
+ + {/* Modal panel */} +
+ {/* Header */} +
+

+ {title} +

+ +
+ + {/* Content */} +
+ {children} +
+
+
+
+ ); +}; + +export default Modal; diff --git a/src/pages/timetable/ClassDetailPage.tsx b/src/pages/timetable/ClassDetailPage.tsx new file mode 100644 index 0000000..917cbc4 --- /dev/null +++ b/src/pages/timetable/ClassDetailPage.tsx @@ -0,0 +1,324 @@ +import React, { useEffect, useState } from 'react'; +import { useParams, Link, useNavigate } from 'react-router-dom'; +import { ArrowLeft, Calendar, Users, BookOpen, Clock, Edit, Trash2, Plus } from 'lucide-react'; +import useTimetableStore from '../../stores/timetableStore'; +import { useProfile } from '../../contexts/ProfileContext'; +import Modal from '../../components/common/Modal'; + +const ClassDetailPage: React.FC = () => { + const { classId } = useParams<{ classId: string }>(); + const navigate = useNavigate(); + const { profile } = useProfile(); + const { + currentClass, + timetables, + enrolledStudents, + classTeachers, + classDetailLoading, + classDetailError, + fetchClassDetail, + deleteClass, + clearCurrentClass, + } = useTimetableStore(); + + const [activeTab, setActiveTab] = useState<'timetables' | 'students' | 'teachers'>('timetables'); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + useEffect(() => { + if (classId) { + fetchClassDetail(classId); + } + return () => { + clearCurrentClass(); + }; + }, [classId, fetchClassDetail, clearCurrentClass]); + + const handleDeleteClass = async () => { + if (!classId) return; + await deleteClass(classId); + navigate('/timetable/classes'); + }; + + const isOwner = currentClass?.created_by === profile?.id; + const isTeacher = classTeachers.some(t => t.teacher_id === profile?.id && t.is_primary); + + if (classDetailLoading) { + return ( +
+
+
+ ); + } + + if (classDetailError || !currentClass) { + return ( +
+
+

Error Loading Class

+

{classDetailError || 'Class not found'}

+ + Back to Classes + +
+
+ ); + } + + return ( +
+ {/* Header */} +
+ + + Back to Classes + + +
+
+
+

{currentClass.name}

+ + {currentClass.subject} + +
+

+ {currentClass.school_year} • {currentClass.academic_term} +

+
+ + {(isOwner || isTeacher) && ( +
+ + + Edit + + +
+ )} +
+
+ + {/* Stats */} +
+
+
+
+ +
+
+

Timetables

+

{currentClass.timetable_count}

+
+
+
+
+
+
+ +
+
+

Students

+

{currentClass.student_count}

+
+
+
+
+
+
+ +
+
+

Teachers

+

{classTeachers.length}

+
+
+
+
+ + {/* Tabs */} +
+
+ + + +
+
+ + {/* Tab Content */} +
+ {activeTab === 'timetables' && ( +
+
+

Timetables

+ {(isOwner || isTeacher) && ( + + + Add Timetable + + )} +
+ + {timetables.length === 0 ? ( +
+ +

No timetables yet

+

Create a timetable to start scheduling lessons

+
+ ) : ( +
+ {timetables.map((timetable) => ( + +
+

{timetable.name}

+

+ {timetable.lesson_count} lessons + {timetable.is_recurring && ' • Recurring'} +

+
+ + + ))} +
+ )} +
+ )} + + {activeTab === 'students' && ( +
+

Enrolled Students

+ {enrolledStudents.length === 0 ? ( +
+ +

No students enrolled

+

Students can request enrollment or be added by teachers

+
+ ) : ( +
+ {enrolledStudents.map((student) => ( +
+
+ + {student.full_name.charAt(0)} + +
+
+

{student.full_name}

+

{student.email}

+
+
+ ))} +
+ )} +
+ )} + + {activeTab === 'teachers' && ( +
+

Teachers

+
+ {classTeachers.map((teacher) => ( +
+
+ + {teacher.full_name.charAt(0)} + +
+
+

{teacher.full_name}

+

{teacher.email}

+
+ {teacher.is_primary && ( + + Primary + + )} +
+ ))} +
+
+ )} +
+ + {/* Delete Modal */} + setShowDeleteModal(false)} + title="Delete Class" + > +
+

+ Are you sure you want to delete "{currentClass.name}"? This action cannot be undone and will remove all timetables, lessons, and whiteboards associated with this class. +

+
+ + +
+
+
+
+ ); +}; + +export default ClassDetailPage; diff --git a/src/pages/timetable/ClassesPage.tsx b/src/pages/timetable/ClassesPage.tsx new file mode 100644 index 0000000..0eb4bd0 --- /dev/null +++ b/src/pages/timetable/ClassesPage.tsx @@ -0,0 +1,246 @@ +import React, { useEffect, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { Plus, Search, Filter, BookOpen, Users, Calendar } from 'lucide-react'; +import useTimetableStore from '../../stores/timetableStore'; +import { useProfile } from '../../contexts/ProfileContext'; + +const ClassesPage: React.FC = () => { + const navigate = useNavigate(); + const { profile } = useProfile(); + const { + classes, + totalCount, + classesLoading, + classesError, + currentPage, + pageSize, + filterSubject, + filterSchoolYear, + filterAcademicTerm, + searchQuery, + fetchClasses, + setFilterSubject, + setFilterSchoolYear, + setFilterAcademicTerm, + setSearchQuery, + setPage, + clearFilters, + } = useTimetableStore(); + + const [showFilters, setShowFilters] = useState(false); + + useEffect(() => { + fetchClasses({ + subject: filterSubject || undefined, + school_year: filterSchoolYear || undefined, + academic_term: filterAcademicTerm || undefined, + search: searchQuery || undefined, + skip: currentPage * pageSize, + limit: pageSize, + }); + }, [filterSubject, filterSchoolYear, filterAcademicTerm, searchQuery, currentPage, pageSize]); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setPage(0); + fetchClasses({ + subject: filterSubject || undefined, + school_year: filterSchoolYear || undefined, + academic_term: filterAcademicTerm || undefined, + search: searchQuery || undefined, + skip: 0, + limit: pageSize, + }); + }; + + const totalPages = Math.ceil(totalCount / pageSize); + + return ( +
+ {/* Header */} +
+
+

Classes

+

+ Manage classes, timetables, and student enrollments +

+
+ {(profile?.role === 'teacher' || profile?.role === 'admin') && ( + + )} +
+ + {/* Search and Filters */} +
+
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+ + +
+ + {showFilters && ( +
+
+ + +
+
+ + +
+
+ + +
+
+ )} +
+ + {/* Error Message */} + {classesError && ( +
+ {classesError} +
+ )} + + {/* Classes Grid */} + {classesLoading ? ( +
+
+
+ ) : classes.length === 0 ? ( +
+ +

No classes found

+

+ {searchQuery || filterSubject || filterSchoolYear + ? 'Try adjusting your filters' + : 'Create your first class to get started'} +

+
+ ) : ( + <> +
+ {classes.map((cls) => ( + +
+
+ +
+ + {cls.subject} + +
+

{cls.name}

+

+ {cls.school_year} • {cls.academic_term} +

+
+ + + {cls.student_count} students + + + + {cls.timetable_count} timetables + +
+ + ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {currentPage + 1} of {totalPages} + + +
+ )} + + )} +
+ ); +}; + +export default ClassesPage; diff --git a/src/pages/timetable/EnrollmentRequestsPage.tsx b/src/pages/timetable/EnrollmentRequestsPage.tsx new file mode 100644 index 0000000..d0fb9f6 --- /dev/null +++ b/src/pages/timetable/EnrollmentRequestsPage.tsx @@ -0,0 +1,335 @@ +import React, { useEffect, useState } from 'react'; +import { Check, X, UserPlus, Users, Filter, Search, ChevronDown } from 'lucide-react'; +import useTimetableStore from '../../stores/timetableStore'; +import Modal from '../../components/common/Modal'; + +interface RequestFilters { + class_id?: string; + status: 'pending' | 'approved' | 'rejected' | 'all'; + search: string; +} + +const EnrollmentRequestsPage: React.FC = () => { + const { + myClasses, + enrollmentRequests, + enrollmentRequestsLoading, + enrollmentRequestsError, + fetchMyClasses, + fetchEnrollmentRequests, + respondToEnrollmentRequest, + } = useTimetableStore(); + + const [filters, setFilters] = useState({ + status: 'pending', + search: '', + }); + const [selectedRequest, setSelectedRequest] = useState(null); + const [actionType, setActionType] = useState<'approve' | 'reject' | null>(null); + const [notes, setNotes] = useState(''); + const [processing, setProcessing] = useState(false); + + // Get classes where user is teacher + const teachingClasses = myClasses.filter(c => c.role === 'teacher'); + + useEffect(() => { + fetchMyClasses(); + fetchEnrollmentRequests({ + class_id: filters.class_id, + status: filters.status === 'all' ? undefined : filters.status, + }); + }, [fetchMyClasses, fetchEnrollmentRequests, filters.class_id, filters.status]); + + const handleRespond = async () => { + if (!selectedRequest || !actionType) return; + + setProcessing(true); + try { + await respondToEnrollmentRequest(selectedRequest, { + status: actionType, + notes: notes || undefined, + }); + setSelectedRequest(null); + setActionType(null); + setNotes(''); + } finally { + setProcessing(false); + } + }; + + // Filter requests by search + const filteredRequests = enrollmentRequests.filter(request => { + if (!filters.search) return true; + const searchLower = filters.search.toLowerCase(); + return ( + request.student?.first_name?.toLowerCase().includes(searchLower) || + request.student?.last_name?.toLowerCase().includes(searchLower) || + request.student?.email?.toLowerCase().includes(searchLower) || + request.class?.name?.toLowerCase().includes(searchLower) + ); + }); + + const getStatusBadge = (status: string) => { + switch (status) { + case 'pending': + return Pending; + case 'approved': + return Approved; + case 'rejected': + return Rejected; + default: + return {status}; + } + }; + + if (enrollmentRequestsLoading && enrollmentRequests.length === 0) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+

Enrollment Requests

+

+ Manage student enrollment requests for your classes +

+
+ + {/* Filters */} +
+
+ {/* Class Filter */} +
+ + +
+ + {/* Status Filter */} +
+ +
+ + {/* Search */} +
+
+ + setFilters(prev => ({ ...prev, search: e.target.value }))} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500" + /> +
+
+
+
+ + {/* Error */} + {enrollmentRequestsError && ( +
+

Error loading requests: {enrollmentRequestsError}

+ +
+ )} + + {/* Requests Table */} + {filteredRequests.length > 0 ? ( +
+ + + + + + + + + + + + {filteredRequests.map((request) => ( + + + + + + + + ))} + +
+ Student + + Class + + Requested + + Status + + Actions +
+
+
+ +
+
+
+ {request.student?.first_name} {request.student?.last_name} +
+
+ {request.student?.email} +
+
+
+
+
{request.class?.name}
+ {request.class?.code && ( +
{request.class.code}
+ )} +
+ {new Date(request.requested_at).toLocaleDateString()} + + {getStatusBadge(request.status)} + + {request.status === 'pending' && ( +
+ + +
+ )} + {request.status !== 'pending' && ( + + {request.responded_at && `Responded ${new Date(request.responded_at).toLocaleDateString()}`} + + )} +
+
+ ) : ( +
+ +

No enrollment requests

+

+ {filters.status === 'pending' + ? 'There are no pending enrollment requests for your classes.' + : 'No enrollment requests match your filters.' + } +

+
+ )} + + {/* Response Modal */} + { + setSelectedRequest(null); + setActionType(null); + setNotes(''); + }} + title={actionType === 'approve' ? 'Approve Enrollment' : 'Reject Enrollment'} + maxWidth="max-w-md" + > +
+

+ {actionType === 'approve' + ? 'Are you sure you want to approve this enrollment request? The student will be added to the class.' + : 'Are you sure you want to reject this enrollment request?' + } +

+ +
+ +