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,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;
|
||||
Reference in New Issue
Block a user