feat(timetable): add page components, services, stores and types
- Add timetable page components: - ClassesListPage.tsx (browse and search classes) - MyClassesPage.tsx (student enrolled classes) - EnrollmentRequestsPage.tsx (teacher approval interface) - TimetablePage.tsx (weekly schedule view) - LessonViewPage.tsx (TLDraw-integrated lesson view) - Add timetableService.ts for API communication - Add timetableStore.ts for state management - Add timetable.types.ts for TypeScript definitions - Add common components (LoadingSpinner, ErrorMessage, EmptyState) - Add .env.development with local development configuration
This commit is contained in:
@@ -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 (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (classDetailError || !currentClass) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-red-800 mb-2">Error Loading Class</h2>
|
||||
<p className="text-red-600">{classDetailError || 'Class not found'}</p>
|
||||
<Link to="/timetable/classes" className="text-blue-600 hover:underline mt-4 inline-block">
|
||||
Back to Classes
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to="/timetable/classes"
|
||||
className="inline-flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
Back to Classes
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold text-gray-900">{currentClass.name}</h1>
|
||||
<span className="px-3 py-1 bg-blue-100 text-blue-700 text-sm font-medium rounded-full">
|
||||
{currentClass.subject}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-500">
|
||||
{currentClass.school_year} • {currentClass.academic_term}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(isOwner || isTeacher) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/timetable/classes/${classId}/edit`}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<Edit size={18} />
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-50 rounded-lg">
|
||||
<Calendar className="text-blue-600" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Timetables</p>
|
||||
<p className="text-xl font-semibold text-gray-900">{currentClass.timetable_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-50 rounded-lg">
|
||||
<Users className="text-green-600" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Students</p>
|
||||
<p className="text-xl font-semibold text-gray-900">{currentClass.student_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-50 rounded-lg">
|
||||
<BookOpen className="text-purple-600" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Teachers</p>
|
||||
<p className="text-xl font-semibold text-gray-900">{classTeachers.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<div className="flex gap-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('timetables')}
|
||||
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'timetables'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Timetables
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('students')}
|
||||
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'students'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Students ({enrolledStudents.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('teachers')}
|
||||
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'teachers'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Teachers ({classTeachers.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
{activeTab === 'timetables' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Timetables</h2>
|
||||
{(isOwner || isTeacher) && (
|
||||
<Link
|
||||
to={`/timetable/classes/${classId}/timetables/new`}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Add Timetable
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{timetables.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Clock size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-2">No timetables yet</p>
|
||||
<p>Create a timetable to start scheduling lessons</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{timetables.map((timetable) => (
|
||||
<Link
|
||||
key={timetable.id}
|
||||
to={`/timetable/timetables/${timetable.id}`}
|
||||
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{timetable.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{timetable.lesson_count} lessons
|
||||
{timetable.is_recurring && ' • Recurring'}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowLeft className="rotate-180 text-gray-400" size={18} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'students' && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Enrolled Students</h2>
|
||||
{enrolledStudents.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Users size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-2">No students enrolled</p>
|
||||
<p>Students can request enrollment or be added by teachers</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{enrolledStudents.map((student) => (
|
||||
<div
|
||||
key={student.user_id}
|
||||
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-gray-600 font-medium">
|
||||
{student.full_name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{student.full_name}</p>
|
||||
<p className="text-sm text-gray-500">{student.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'teachers' && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Teachers</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{classTeachers.map((teacher) => (
|
||||
<div
|
||||
key={teacher.teacher_id}
|
||||
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 font-medium">
|
||||
{teacher.full_name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">{teacher.full_name}</p>
|
||||
<p className="text-sm text-gray-500">{teacher.email}</p>
|
||||
</div>
|
||||
{teacher.is_primary && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs font-medium rounded-full">
|
||||
Primary
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<Modal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
title="Delete Class"
|
||||
>
|
||||
<div className="p-6">
|
||||
<p className="text-gray-600 mb-6">
|
||||
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.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteClass}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Delete Class
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClassDetailPage;
|
||||
@@ -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 (
|
||||
<div className="container mx-auto px-4 py-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Classes</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Manage classes, timetables, and student enrollments
|
||||
</p>
|
||||
</div>
|
||||
{(profile?.role === 'teacher' || profile?.role === 'admin') && (
|
||||
<button
|
||||
onClick={() => navigate('/timetable/classes/create')}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Create Class
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
|
||||
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search classes by name or subject..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Filter size={20} />
|
||||
Filters
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{showFilters && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4 pt-4 border-t border-gray-200">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subject</label>
|
||||
<select
|
||||
value={filterSubject || ''}
|
||||
onChange={(e) => setFilterSubject(e.target.value || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Subjects</option>
|
||||
<option value="Mathematics">Mathematics</option>
|
||||
<option value="Science">Science</option>
|
||||
<option value="English">English</option>
|
||||
<option value="History">History</option>
|
||||
<option value="Geography">Geography</option>
|
||||
<option value="Art">Art</option>
|
||||
<option value="Music">Music</option>
|
||||
<option value="Physical Education">Physical Education</option>
|
||||
<option value="Computer Science">Computer Science</option>
|
||||
<option value="Foreign Language">Foreign Language</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">School Year</label>
|
||||
<select
|
||||
value={filterSchoolYear || ''}
|
||||
onChange={(e) => setFilterSchoolYear(e.target.value || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Years</option>
|
||||
<option value="2025-2026">2025-2026</option>
|
||||
<option value="2024-2025">2024-2025</option>
|
||||
<option value="2023-2024">2023-2024</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Term</label>
|
||||
<select
|
||||
value={filterAcademicTerm || ''}
|
||||
onChange={(e) => setFilterAcademicTerm(e.target.value || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Terms</option>
|
||||
<option value="Fall">Fall</option>
|
||||
<option value="Spring">Spring</option>
|
||||
<option value="Summer">Summer</option>
|
||||
<option value="Full Year">Full Year</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{classesError && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||
{classesError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Classes Grid */}
|
||||
{classesLoading ? (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : classes.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-xl">
|
||||
<BookOpen className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900">No classes found</h3>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{searchQuery || filterSubject || filterSchoolYear
|
||||
? 'Try adjusting your filters'
|
||||
: 'Create your first class to get started'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{classes.map((cls) => (
|
||||
<Link
|
||||
key={cls.id}
|
||||
to={`/timetable/classes/${cls.id}`}
|
||||
className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md hover:border-blue-300 transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="p-2 bg-blue-50 rounded-lg">
|
||||
<BookOpen className="text-blue-600" size={24} />
|
||||
</div>
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-600 text-xs font-medium rounded-full">
|
||||
{cls.subject}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">{cls.name}</h3>
|
||||
<p className="text-gray-500 text-sm mb-4">
|
||||
{cls.school_year} • {cls.academic_term}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={16} />
|
||||
{cls.student_count} students
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar size={16} />
|
||||
{cls.timetable_count} timetables
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 mt-8">
|
||||
<button
|
||||
onClick={() => setPage(currentPage - 1)}
|
||||
disabled={currentPage === 0}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-gray-600">
|
||||
Page {currentPage + 1} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages - 1}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClassesPage;
|
||||
@@ -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<RequestFilters>({
|
||||
status: 'pending',
|
||||
search: '',
|
||||
});
|
||||
const [selectedRequest, setSelectedRequest] = useState<string | null>(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 <span className="px-2 py-1 bg-yellow-100 text-yellow-700 text-xs font-medium rounded-full">Pending</span>;
|
||||
case 'approved':
|
||||
return <span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full">Approved</span>;
|
||||
case 'rejected':
|
||||
return <span className="px-2 py-1 bg-red-100 text-red-700 text-xs font-medium rounded-full">Rejected</span>;
|
||||
default:
|
||||
return <span className="px-2 py-1 bg-gray-100 text-gray-700 text-xs font-medium rounded-full">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
if (enrollmentRequestsLoading && enrollmentRequests.length === 0) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Enrollment Requests</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Manage student enrollment requests for your classes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* Class Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={18} className="text-gray-400" />
|
||||
<select
|
||||
value={filters.class_id || ''}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, class_id: e.target.value || undefined }))}
|
||||
className="border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">All Classes</option>
|
||||
{teachingClasses.map(cls => (
|
||||
<option key={cls.class_id} value={cls.class_id}>
|
||||
{cls.class?.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value as RequestFilters['status'] }))}
|
||||
className="border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="all">All Statuses</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="relative">
|
||||
<Search size={18} className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by student name or email..."
|
||||
value={filters.search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{enrollmentRequestsError && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
||||
<p>Error loading requests: {enrollmentRequestsError}</p>
|
||||
<button
|
||||
onClick={() => fetchEnrollmentRequests({
|
||||
class_id: filters.class_id,
|
||||
status: filters.status === 'all' ? undefined : filters.status,
|
||||
})}
|
||||
className="mt-2 text-sm font-medium text-red-600 hover:text-red-800"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Requests Table */}
|
||||
{filteredRequests.length > 0 ? (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Student
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Class
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Requested
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredRequests.map((request) => (
|
||||
<tr key={request.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center">
|
||||
<UserPlus size={18} className="text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{request.student?.first_name} {request.student?.last_name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{request.student?.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{request.class?.name}</div>
|
||||
{request.class?.code && (
|
||||
<div className="text-sm text-gray-500">{request.class.code}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(request.requested_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(request.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
{request.status === 'pending' && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedRequest(request.id);
|
||||
setActionType('approve');
|
||||
setNotes('');
|
||||
}}
|
||||
className="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-green-700 bg-green-100 hover:bg-green-200"
|
||||
>
|
||||
<Check size={14} className="mr-1" />
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedRequest(request.id);
|
||||
setActionType('reject');
|
||||
setNotes('');
|
||||
}}
|
||||
className="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200"
|
||||
>
|
||||
<X size={14} className="mr-1" />
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{request.status !== 'pending' && (
|
||||
<span className="text-gray-400 text-xs">
|
||||
{request.responded_at && `Responded ${new Date(request.responded_at).toLocaleDateString()}`}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 bg-white rounded-lg shadow">
|
||||
<Users className="mx-auto h-12 w-12 text-gray-300" />
|
||||
<h3 className="mt-4 text-lg font-medium text-gray-900">No enrollment requests</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
{filters.status === 'pending'
|
||||
? 'There are no pending enrollment requests for your classes.'
|
||||
: 'No enrollment requests match your filters.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Response Modal */}
|
||||
<Modal
|
||||
isOpen={!!selectedRequest && !!actionType}
|
||||
onClose={() => {
|
||||
setSelectedRequest(null);
|
||||
setActionType(null);
|
||||
setNotes('');
|
||||
}}
|
||||
title={actionType === 'approve' ? 'Approve Enrollment' : 'Reject Enrollment'}
|
||||
maxWidth="max-w-md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
{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?'
|
||||
}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label htmlFor="notes" className="block text-sm font-medium text-gray-700">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={actionType === 'approve' ? 'Add a welcome message...' : 'Reason for rejection...'}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedRequest(null);
|
||||
setActionType(null);
|
||||
setNotes('');
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRespond}
|
||||
disabled={processing}
|
||||
className={`px-4 py-2 border border-transparent rounded-md text-sm font-medium text-white ${
|
||||
actionType === 'approve'
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-red-600 hover:bg-red-700'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{processing ? 'Processing...' : actionType === 'approve' ? 'Approve' : 'Reject'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnrollmentRequestsPage;
|
||||
@@ -0,0 +1,293 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Calendar, Clock, MapPin, Edit, Trash2, Users, BookOpen, FileText, CheckCircle, XCircle } from 'lucide-react';
|
||||
import useTimetableStore from '../../stores/timetableStore';
|
||||
import { useProfile } from '../../contexts/ProfileContext';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import Modal from '../../components/common/Modal';
|
||||
|
||||
const LessonPage: React.FC = () => {
|
||||
const { lessonId } = useParams<{ lessonId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { profile } = useProfile();
|
||||
const {
|
||||
currentLesson,
|
||||
currentTimetable,
|
||||
currentClass,
|
||||
lessonDetailLoading,
|
||||
lessonDetailError,
|
||||
fetchLessonDetail,
|
||||
deleteLesson,
|
||||
updateAttendance,
|
||||
clearCurrentLesson,
|
||||
} = useTimetableStore();
|
||||
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [attendanceUpdating, setAttendanceUpdating] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (lessonId) {
|
||||
fetchLessonDetail(lessonId);
|
||||
}
|
||||
return () => {
|
||||
clearCurrentLesson();
|
||||
};
|
||||
}, [lessonId, fetchLessonDetail, clearCurrentLesson]);
|
||||
|
||||
const handleDeleteLesson = async () => {
|
||||
if (!lessonId) return;
|
||||
if (confirm('Are you sure you want to delete this lesson?')) {
|
||||
await deleteLesson(lessonId);
|
||||
navigate(`/timetable/timetables/${currentTimetable?.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAttendanceUpdate = async (studentId: string, status: 'present' | 'absent' | 'late' | 'excused') => {
|
||||
if (!lessonId) return;
|
||||
setAttendanceUpdating(studentId);
|
||||
await updateAttendance(lessonId, studentId, status);
|
||||
setAttendanceUpdating(null);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'present': return 'bg-green-100 text-green-700';
|
||||
case 'absent': return 'bg-red-100 text-red-700';
|
||||
case 'late': return 'bg-yellow-100 text-yellow-700';
|
||||
case 'excused': return 'bg-blue-100 text-blue-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
const isTeacher = profile?.role === 'teacher' || profile?.role === 'admin';
|
||||
const isOwner = currentClass?.teacher_id === profile?.id;
|
||||
const canManage = isTeacher && isOwner;
|
||||
|
||||
if (lessonDetailLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (lessonDetailError) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-700">{lessonDetailError}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentLesson) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">Lesson not found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-4">
|
||||
<ol className="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<li>
|
||||
<Link to="/timetable" className="hover:text-blue-600">Timetable</Link>
|
||||
</li>
|
||||
<li>/</li>
|
||||
<li>
|
||||
<Link to={`/timetable/classes/${currentClass?.id}`} className="hover:text-blue-600">
|
||||
{currentClass?.name}
|
||||
</Link>
|
||||
</li>
|
||||
<li>/</li>
|
||||
<li>
|
||||
<Link to={`/timetable/timetables/${currentTimetable?.id}`} className="hover:text-blue-600">
|
||||
{currentTimetable?.title}
|
||||
</Link>
|
||||
</li>
|
||||
<li>/</li>
|
||||
<li className="text-gray-900 font-medium">Lesson</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<Link
|
||||
to={`/timetable/timetables/${currentTimetable?.id}`}
|
||||
className="inline-flex items-center text-gray-500 hover:text-gray-700 mb-2"
|
||||
>
|
||||
<ArrowLeft size={16} className="mr-1" />
|
||||
Back to Timetable
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{currentLesson.title}</h1>
|
||||
{currentLesson.description && (
|
||||
<p className="text-gray-600 mt-1">{currentLesson.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setIsEditModalOpen(true)}
|
||||
className="inline-flex items-center px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Edit size={16} className="mr-2" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteLesson}
|
||||
className="inline-flex items-center px-3 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} className="mr-2" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lesson Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<Clock size={20} className="mr-2 text-blue-600" />
|
||||
Time & Location
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Calendar size={18} className="text-gray-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Date</p>
|
||||
<p className="text-gray-600">
|
||||
{format(parseISO(currentLesson.start_time), 'EEEE, MMMM d, yyyy')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock size={18} className="text-gray-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Time</p>
|
||||
<p className="text-gray-600">
|
||||
{format(parseISO(currentLesson.start_time), 'HH:mm')} - {format(parseISO(currentLesson.end_time), 'HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{(currentLesson.location || currentLesson.room) && (
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPin size={18} className="text-gray-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Location</p>
|
||||
<p className="text-gray-600">
|
||||
{currentLesson.location}
|
||||
{currentLesson.location && currentLesson.room && ' - '}
|
||||
{currentLesson.room && `Room ${currentLesson.room}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<BookOpen size={20} className="mr-2 text-green-600" />
|
||||
Class Information
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Class</p>
|
||||
<p className="font-medium text-gray-900">{currentClass?.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Timetable</p>
|
||||
<p className="font-medium text-gray-900">{currentTimetable?.title}</p>
|
||||
</div>
|
||||
{currentLesson.subject && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Subject</p>
|
||||
<p className="font-medium text-gray-900">{currentLesson.subject}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attendance Section */}
|
||||
{currentLesson.attendance && currentLesson.attendance.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Users size={20} className="mr-2 text-purple-600" />
|
||||
Attendance
|
||||
</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{currentLesson.attendance.map((record) => (
|
||||
<div key={record.student_id} className="px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-medium">
|
||||
{record.student?.first_name?.[0]}{record.student?.last_name?.[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{record.student?.first_name} {record.student?.last_name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{record.student?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={record.status}
|
||||
onChange={(e) => handleAttendanceUpdate(record.student_id, e.target.value as any)}
|
||||
disabled={attendanceUpdating === record.student_id}
|
||||
className="text-sm border border-gray-300 rounded-md px-2 py-1 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="present">Present</option>
|
||||
<option value="absent">Absent</option>
|
||||
<option value="late">Late</option>
|
||||
<option value="excused">Excused</option>
|
||||
</select>
|
||||
{attendanceUpdating === record.student_id && (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded capitalize ${getStatusColor(record.status)}`}>
|
||||
{record.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Modal Placeholder */}
|
||||
<Modal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => setIsEditModalOpen(false)}
|
||||
title="Edit Lesson"
|
||||
>
|
||||
<p className="text-gray-600">Lesson editing form would go here...</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setIsEditModalOpen(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsEditModalOpen(false)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LessonPage;
|
||||
@@ -0,0 +1,222 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BookOpen, Users, Clock, ChevronRight, GraduationCap, School } from 'lucide-react';
|
||||
import useTimetableStore from '../../stores/timetableStore';
|
||||
import { useProfile } from '../../contexts/ProfileContext';
|
||||
|
||||
const MyClassesPage: React.FC = () => {
|
||||
const { profile } = useProfile();
|
||||
const {
|
||||
myClasses,
|
||||
myClassesLoading,
|
||||
myClassesError,
|
||||
fetchMyClasses,
|
||||
} = useTimetableStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchMyClasses();
|
||||
}, [fetchMyClasses]);
|
||||
|
||||
const getRoleLabel = (role: string) => {
|
||||
switch (role) {
|
||||
case 'teacher': return { text: 'Teacher', color: 'bg-purple-100 text-purple-700' };
|
||||
case 'student': return { text: 'Student', color: 'bg-green-100 text-green-700' };
|
||||
case 'assistant': return { text: 'Assistant', color: 'bg-blue-100 text-blue-700' };
|
||||
default: return { text: role, color: 'bg-gray-100 text-gray-700' };
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return { text: 'Active', color: 'bg-green-100 text-green-700' };
|
||||
case 'pending': return { text: 'Pending', color: 'bg-yellow-100 text-yellow-700' };
|
||||
case 'completed': return { text: 'Completed', color: 'bg-gray-100 text-gray-700' };
|
||||
case 'cancelled': return { text: 'Cancelled', color: 'bg-red-100 text-red-700' };
|
||||
default: return { text: status, color: 'bg-gray-100 text-gray-700' };
|
||||
}
|
||||
};
|
||||
|
||||
if (myClassesLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (myClassesError) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
||||
<p>Error loading your classes: {myClassesError}</p>
|
||||
<button
|
||||
onClick={() => fetchMyClasses()}
|
||||
className="mt-2 text-sm font-medium text-red-600 hover:text-red-800"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Separate classes by role
|
||||
const teachingClasses = myClasses.filter(c => c.role === 'teacher' || c.role === 'assistant');
|
||||
const enrolledClasses = myClasses.filter(c => c.role === 'student');
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">My Classes</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Manage your enrolled classes and teaching assignments
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Teaching Section */}
|
||||
{teachingClasses.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<GraduationCap className="text-purple-600" size={24} />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Teaching</h2>
|
||||
<span className="px-2 py-1 bg-purple-100 text-purple-700 text-sm font-medium rounded-full">
|
||||
{teachingClasses.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{teachingClasses.map((classItem) => (
|
||||
<Link
|
||||
key={classItem.class_id}
|
||||
to={`/timetable/classes/${classItem.class_id}`}
|
||||
className="block bg-white rounded-lg shadow hover:shadow-md transition-shadow border border-gray-200"
|
||||
>
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{classItem.class?.name}
|
||||
</h3>
|
||||
{classItem.class?.code && (
|
||||
<p className="text-sm text-gray-500">{classItem.class.code}</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="text-gray-400 flex-shrink-0 ml-2" size={20} />
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
|
||||
{classItem.class?.description || 'No description'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${getRoleLabel(classItem.role).color}`}>
|
||||
{getRoleLabel(classItem.role).text}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${getStatusLabel(classItem.status).color}`}>
|
||||
{getStatusLabel(classItem.status).text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center gap-4 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Users size={14} />
|
||||
<span>{classItem.class?.enrolled_count || 0} students</span>
|
||||
</div>
|
||||
{classItem.class?.academic_year && (
|
||||
<div className="flex items-center gap-1">
|
||||
<School size={14} />
|
||||
<span>{classItem.class.academic_year}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enrolled Section */}
|
||||
{enrolledClasses.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BookOpen className="text-green-600" size={24} />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Enrolled</h2>
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 text-sm font-medium rounded-full">
|
||||
{enrolledClasses.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{enrolledClasses.map((classItem) => (
|
||||
<Link
|
||||
key={classItem.class_id}
|
||||
to={`/timetable/classes/${classItem.class_id}`}
|
||||
className="block bg-white rounded-lg shadow hover:shadow-md transition-shadow border border-gray-200"
|
||||
>
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{classItem.class?.name}
|
||||
</h3>
|
||||
{classItem.class?.code && (
|
||||
<p className="text-sm text-gray-500">{classItem.class.code}</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="text-gray-400 flex-shrink-0 ml-2" size={20} />
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
|
||||
{classItem.class?.description || 'No description'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${getStatusLabel(classItem.status).color}`}>
|
||||
{getStatusLabel(classItem.status).text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center gap-4 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<GraduationCap size={14} />
|
||||
<span>
|
||||
{classItem.class?.teachers?.[0]?.first_name} {classItem.class?.teachers?.[0]?.last_name}
|
||||
</span>
|
||||
</div>
|
||||
{classItem.class?.academic_year && (
|
||||
<div className="flex items-center gap-1">
|
||||
<School size={14} />
|
||||
<span>{classItem.class.academic_year}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{myClasses.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<BookOpen className="mx-auto h-12 w-12 text-gray-300" />
|
||||
<h3 className="mt-4 text-lg font-medium text-gray-900">No classes found</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
You are not enrolled in or teaching any classes yet.
|
||||
</p>
|
||||
<Link
|
||||
to="/timetable/classes"
|
||||
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200"
|
||||
>
|
||||
Browse Available Classes
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyClassesPage;
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Calendar, Clock, MapPin, Edit, Trash2, Plus, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import useTimetableStore from '../../stores/timetableStore';
|
||||
import { useProfile } from '../../contexts/ProfileContext';
|
||||
import { format, parseISO, addDays, startOfWeek, isSameDay } from 'date-fns';
|
||||
|
||||
const TimetablePage: React.FC = () => {
|
||||
const { timetableId } = useParams<{ timetableId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { profile } = useProfile();
|
||||
const {
|
||||
currentTimetable,
|
||||
currentLessons,
|
||||
timetableDetailLoading,
|
||||
timetableDetailError,
|
||||
fetchTimetableDetail,
|
||||
deleteTimetable,
|
||||
clearCurrentTimetable,
|
||||
} = useTimetableStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (timetableId) {
|
||||
fetchTimetableDetail(timetableId);
|
||||
}
|
||||
return () => {
|
||||
clearCurrentTimetable();
|
||||
};
|
||||
}, [timetableId, fetchTimetableDetail, clearCurrentTimetable]);
|
||||
|
||||
const handleDeleteTimetable = async () => {
|
||||
if (!timetableId) return;
|
||||
if (confirm('Are you sure you want to delete this timetable?')) {
|
||||
await deleteTimetable(timetableId);
|
||||
navigate(`/timetable/classes/${currentTimetable?.class_id}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Group lessons by day
|
||||
const lessonsByDay = currentLessons.reduce((acc, lesson) => {
|
||||
const date = lesson.day_of_week || format(parseISO(lesson.start_time), 'yyyy-MM-dd');
|
||||
if (!acc[date]) acc[date] = [];
|
||||
acc[date].push(lesson);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof currentLessons>);
|
||||
|
||||
// Sort lessons within each day by start time
|
||||
Object.keys(lessonsByDay).forEach(day => {
|
||||
lessonsByDay[day].sort((a, b) =>
|
||||
new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
if (timetableDetailLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (timetableDetailError || !currentTimetable) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-red-800 mb-2">Error Loading Timetable</h2>
|
||||
<p className="text-red-600">{timetableDetailError || 'Timetable not found'}</p>
|
||||
<Link to="/timetable/classes" className="text-blue-600 hover:underline mt-4 inline-block">
|
||||
Back to Classes
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to={`/timetable/classes/${currentTimetable.class_id}`}
|
||||
className="inline-flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
Back to Class
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{currentTimetable.name}</h1>
|
||||
<div className="flex items-center gap-4 text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar size={16} />
|
||||
{format(parseISO(currentTimetable.effective_from), 'MMM d, yyyy')}
|
||||
{currentTimetable.effective_until && ` - ${format(parseISO(currentTimetable.effective_until), 'MMM d, yyyy')}`}
|
||||
</span>
|
||||
{currentTimetable.is_recurring && (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full">
|
||||
Recurring
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/timetable/timetables/${timetableId}/lessons/new`}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Add Lesson
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleDeleteTimetable}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lessons List */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Lessons ({currentLessons.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{currentLessons.length === 0 ? (
|
||||
<div className="p-12 text-center text-gray-500">
|
||||
<Clock size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-2">No lessons scheduled</p>
|
||||
<p>Add lessons to build your timetable</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{Object.entries(lessonsByDay).map(([day, lessons]) => (
|
||||
<div key={day} className="p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-4">
|
||||
{currentTimetable.is_recurring
|
||||
? `Day ${day}`
|
||||
: format(parseISO(day), 'EEEE, MMMM d, yyyy')}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{lessons.map((lesson) => (
|
||||
<Link
|
||||
key={lesson.id}
|
||||
to={`/timetable/lessons/${lesson.id}`}
|
||||
className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 w-16 text-center">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{format(parseISO(lesson.start_time), 'HH:mm')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{format(parseISO(lesson.end_time), 'HH:mm')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-gray-900 truncate">
|
||||
{lesson.title}
|
||||
</h4>
|
||||
{lesson.description && (
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{lesson.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{lesson.location && (
|
||||
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
<MapPin size={14} />
|
||||
{lesson.location}
|
||||
</div>
|
||||
)}
|
||||
{lesson.room && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs font-medium rounded">
|
||||
Room {lesson.room}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimetablePage;
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Timetable Module - Page Exports
|
||||
*
|
||||
* This barrel file exports all pages related to the timetable/classroom management system.
|
||||
*
|
||||
* @module pages/timetable
|
||||
*/
|
||||
|
||||
// Main pages
|
||||
export { default as TimetablePage } from './TimetablePage';
|
||||
export { default as ClassesPage } from './ClassesPage';
|
||||
export { default as LessonPage } from './LessonPage';
|
||||
|
||||
// Class management pages
|
||||
export { default as MyClassesPage } from './MyClassesPage';
|
||||
export { default as EnrollmentRequestsPage } from './EnrollmentRequestsPage';
|
||||
|
||||
// Re-export types if needed
|
||||
export type { ClassView, DaySchedule, WeekSchedule } from './TimetablePage';
|
||||
Reference in New Issue
Block a user