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:
parent
d5c53f2c17
commit
11c139b410
40
.env.development
Normal file
40
.env.development
Normal file
@ -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
|
||||
49
src/components/common/Modal.tsx
Normal file
49
src/components/common/Modal.tsx
Normal file
@ -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<ModalProps> = ({ isOpen, onClose, title, children, maxWidth = 'max-w-lg' }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-screen items-center justify-center p-4 text-center sm:p-0">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal panel */}
|
||||
<div className={`relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 w-full ${maxWidth}`}>
|
||||
{/* Header */}
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 flex items-center justify-between border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">
|
||||
{title}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-500 focus:outline-none"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
324
src/pages/timetable/ClassDetailPage.tsx
Normal file
324
src/pages/timetable/ClassDetailPage.tsx
Normal file
@ -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;
|
||||
246
src/pages/timetable/ClassesPage.tsx
Normal file
246
src/pages/timetable/ClassesPage.tsx
Normal file
@ -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;
|
||||
335
src/pages/timetable/EnrollmentRequestsPage.tsx
Normal file
335
src/pages/timetable/EnrollmentRequestsPage.tsx
Normal file
@ -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;
|
||||
293
src/pages/timetable/LessonPage.tsx
Normal file
293
src/pages/timetable/LessonPage.tsx
Normal file
@ -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;
|
||||
222
src/pages/timetable/MyClassesPage.tsx
Normal file
222
src/pages/timetable/MyClassesPage.tsx
Normal file
@ -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;
|
||||
195
src/pages/timetable/TimetablePage.tsx
Normal file
195
src/pages/timetable/TimetablePage.tsx
Normal file
@ -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;
|
||||
19
src/pages/timetable/index.ts
Normal file
19
src/pages/timetable/index.ts
Normal file
@ -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';
|
||||
335
src/services/timetableService.ts
Normal file
335
src/services/timetableService.ts
Normal file
@ -0,0 +1,335 @@
|
||||
import axios from 'axios';
|
||||
import { supabase } from '../supabaseClient';
|
||||
import {
|
||||
Class,
|
||||
ClassWithRelations,
|
||||
ClassFilters,
|
||||
Timetable,
|
||||
TimetableWithRelations,
|
||||
TimetableLesson,
|
||||
Lesson,
|
||||
LessonWithRelations,
|
||||
EnrollmentRequest,
|
||||
EnrollmentRequestWithProfile,
|
||||
} from '../types/timetable.types';
|
||||
|
||||
// API base URL
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
|
||||
// ============================================================================
|
||||
// Helper: Get auth headers
|
||||
// ============================================================================
|
||||
|
||||
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) {
|
||||
throw new Error('No authentication token available');
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Class Service
|
||||
// ============================================================================
|
||||
|
||||
export const classService = {
|
||||
async listClasses(filters?: ClassFilters): Promise<{ classes: Class[]; total: number }> {
|
||||
const headers = await getAuthHeaders();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters?.subject) params.append('subject', filters.subject);
|
||||
if (filters?.school_year) params.append('school_year', filters.school_year);
|
||||
if (filters?.academic_term) params.append('academic_term', filters.academic_term);
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.skip !== undefined) params.append('skip', filters.skip.toString());
|
||||
if (filters?.limit !== undefined) params.append('limit', filters.limit.toString());
|
||||
|
||||
const response = await axios.get(`${API_BASE}/database/timetable/classes?${params}`, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getClass(classId: string): Promise<ClassWithRelations> {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await axios.get(`${API_BASE}/database/timetable/classes/${classId}`, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createClass(data: {
|
||||
name: string;
|
||||
subject: string;
|
||||
school_year: string;
|
||||
academic_term: string;
|
||||
description?: string;
|
||||
}): Promise<Class> {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await axios.post(`${API_BASE}/database/timetable/classes`, data, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateClass(classId: string, data: Partial<Class>): Promise<Class> {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await axios.patch(`${API_BASE}/database/timetable/classes/${classId}`, data, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deleteClass(classId: string): Promise<void> {
|
||||
const headers = await getAuthHeaders();
|
||||
await axios.delete(`${API_BASE}/database/timetable/classes/${classId}`, { headers });
|
||||
},
|
||||
|
||||
async getMyClasses(): Promise<Class[]> {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await axios.get(`${API_BASE}/database/timetable/classes/me/student`, { headers });
|
||||
return response.data.classes;
|
||||
},
|
||||
|
||||
async getMyTeachingClasses(): Promise<Class[]> {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await axios.get(`${API_BASE}/database/timetable/classes/me/teacher`, { headers });
|
||||
return response.data.classes;
|
||||
},
|
||||
|
||||
// Teacher management
|
||||
async addTeacher(classId: string, teacherId: string, isPrimary: boolean = false): Promise<void> {
|
||||
const headers = await getAuthHeaders();
|
||||
await axios.post(
|
||||
`${API_BASE}/database/timetable/classes/${classId}/teachers`,
|
||||
{ teacher_id: teacherId, is_primary: isPrimary },
|
||||
{ headers }
|
||||
);
|
||||
},
|
||||
|
||||
async removeTeacher(classId: string, teacherId: string): Promise<void> {
|
||||
const headers = await getAuthHeaders();
|
||||
await axios.delete(`${API_BASE}/database/timetable/classes/${classId}/teachers/${teacherId}`, { headers });
|
||||
},
|
||||
|
||||
// Student management
|
||||
async addStudent(classId: string, studentId: string): Promise<void> {
|
||||
const headers = await getAuthHeaders();
|
||||
await axios.post(
|
||||
`${API_BASE}/database/timetable/classes/${classId}/students`,
|
||||
{ student_id: studentId },
|
||||
{ headers }
|
||||
);
|
||||
},
|
||||
|
||||
async removeStudent(classId: string, studentId: string): Promise<void> {
|
||||
const headers = await getAuthHeaders();
|
||||
await axios.delete(`${API_BASE}/database/timetable/classes/${classId}/students/${studentId}`, { headers });
|
||||
},
|
||||
|
||||
async leaveClass(classId: string): Promise<void> {
|
||||
const headers = await getAuthHeaders();
|
||||
await axios.post(`${API_BASE}/database/timetable/classes/${classId}/leave`, {}, { headers });
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Timetable Service
|
||||
// ============================================================================
|
||||
|
||||
export const timetableService = {
|
||||
async listTimetables(filters?: {
|
||||
class_id?: string;
|
||||
type?: 'ad-hoc' | 'recurring';
|
||||
active?: boolean;
|
||||
}): Promise<Timetable[]> {
|
||||
const headers = await getAuthHeaders();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters?.class_id) params.append('class_id', filters.class_id);
|
||||
if (filters?.type) params.append('type', filters.type);
|
||||
if (filters?.active !== undefined) params.append('active', filters.active.toString());
|
||||
|
||||
const response = await axios.get(`${API_BASE}/database/timetable/timetables?${params}`, { headers });
|
||||
return response.data.timetables;
|
||||
},
|
||||
|
||||
async getTimetable(timetableId: string): Promise<TimetableWithRelations> {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await axios.get(`${API_BASE}/database/timetable/timetables/${timetableId}`, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createTimetable(data: {
|
||||
class_id: string;
|
||||
name: string;
|
||||
type: 'ad-hoc' | 'recurring';
|
||||
start_date: string;
|
||||
end_date?: string;
|
||||
recurrence_pattern?: Record<string, unknown>;
|
||||
recurrence_rule?: string;
|
||||
whiteboard_type?: 'single' | 'multiple';
|
||||
}): Promise<Timetable> {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await axios.post(`${API_BASE}/database/timetable/timetables`, data, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateTimetable(timetableId: string, data: Partial<Timetable>): Promise<Timetable> {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await axios.patch(`${API_BASE}/database/timetable/timetables/${timetableId}`, data, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deleteTimetable(timetableId: string): Promise<void> {
|
||||
const headers = await getAuthHeaders();
|
||||
await axios.delete(`${API_BASE}/database/timetable/timetables/${timetableId}`, { headers });
|
||||
},
|
||||
|
||||
async generateLessons(timetableId: string, startDate: string, endDate: string): Promise<void> {
|
||||
const headers = await getAuthHeaders();
|
||||
await axios.post(
|
||||
`${API_BASE}/database/timetable/timetables/${timetableId}/generate-lessons`,
|
||||
{ start_date: startDate, end_date: endDate },
|
||||
{ headers }
|
||||
);
|
||||
},
|
||||
|
||||
// Timetable Teachers
|
||||
async addTimetableTeacher(timetableId: string, teacherId: string): Promise<void> {
|
||||
const headers = await getAuthHeaders();
|
||||
await axios.post(
|
||||
`${API_BASE}/database/timetable/timetables/${timetableId}/teachers`,
|
||||
{ teacher_id: teacherId },
|
||||
{ headers }
|
||||
);
|
||||
},
|
||||
|
||||
async removeTimetableTeacher(timetableId: string, teacherId: string): Promise<void> {
|
||||
const headers = await getAuthHeaders();
|
||||
await axios.delete(`${API_BASE}/database/timetable/timetables/${timetableId}/teachers/${teacherId}`, { headers });
|
||||
},
|
||||
|
||||
// Lessons from timetable
|
||||
async getTimetableLessons(timetableId: string): Promise<TimetableLesson[]> {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await axios.get(`${API_BASE}/database/timetable/timetables/${timetableId}/lessons`, { headers });
|
||||
return response.data.lessons;
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Lesson Service
|
||||
// ============================================================================
|
||||
|
||||
export const lessonService = {
|
||||
async listLessons(filters?: {
|
||||
timetable_id?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
status?: 'scheduled' | 'in-progress' | 'completed' | 'cancelled';
|
||||
}): Promise<Lesson[]> {
|
||||
const headers = await getAuthHeaders();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters?.timetable_id) params.append('timetable_id', filters.timetable_id);
|
||||
if (filters?.start_date) params.append('start_date', filters.start_date);
|
||||
if (filters?.end_date) params.append('end_date', filters.end_date);
|
||||
if (filters?.status) params.append('status', filters.status);
|
||||
|
||||
const response = await axios.get(`${API_BASE}/database/timetable/lessons?${params}`, { headers });
|
||||
return response.data.lessons;
|
||||
},
|
||||
|
||||
async getLesson(lessonId: string): Promise<LessonWithRelations> {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await axios.get(`${API_BASE}/database/timetable/lessons/${lessonId}`, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createLesson(data: {
|
||||
timetable_id: string;
|
||||
scheduled_start: string;
|
||||
scheduled_end: string;
|
||||
teacher_id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}): Promise<Lesson> {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await axios.post(`${API_BASE}/database/timetable/lessons`, data, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateLesson(lessonId: string, data: Partial<Lesson>): Promise<Lesson> {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await axios.patch(`${API_BASE}/database/timetable/lessons/${lessonId}`, data, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deleteLesson(lessonId: string): Promise<void> {
|
||||
const headers = await getAuthHeaders();
|
||||
await axios.delete(`${API_BASE}/database/timetable/lessons/${lessonId}`, { headers });
|
||||
},
|
||||
|
||||
async startLesson(lessonId: string): Promise<void> {
|
||||
const headers = await getAuthHeaders();
|
||||
await axios.post(`${API_BASE}/database/timetable/lessons/${lessonId}/start`, {}, { headers });
|
||||
},
|
||||
|
||||
async endLesson(lessonId: string): Promise<void> {
|
||||
const headers = await getAuthHeaders();
|
||||
await axios.post(`${API_BASE}/database/timetable/lessons/${lessonId}/end`, {}, { headers });
|
||||
},
|
||||
|
||||
async cancelLesson(lessonId: string, reason?: string): Promise<void> {
|
||||
const headers = await getAuthHeaders();
|
||||
await axios.post(
|
||||
`${API_BASE}/database/timetable/lessons/${lessonId}/cancel`,
|
||||
{ reason },
|
||||
{ headers }
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Enrollment Request Service
|
||||
// ============================================================================
|
||||
|
||||
export const enrollmentService = {
|
||||
async requestEnrollment(classId: string, message?: string): Promise<EnrollmentRequest> {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await axios.post(
|
||||
`${API_BASE}/database/timetable/classes/${classId}/enroll`,
|
||||
{ message },
|
||||
{ headers }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async listEnrollmentRequests(classId: string): Promise<EnrollmentRequestWithProfile[]> {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await axios.get(`${API_BASE}/database/timetable/classes/${classId}/enrollment-requests`, { headers });
|
||||
return response.data.requests;
|
||||
},
|
||||
|
||||
async respondToEnrollment(requestId: string, status: 'approved' | 'rejected'): Promise<void> {
|
||||
const headers = await getAuthHeaders();
|
||||
await axios.post(
|
||||
`${API_BASE}/database/timetable/enrollment-requests/${requestId}/respond`,
|
||||
{ status },
|
||||
{ headers }
|
||||
);
|
||||
},
|
||||
|
||||
async cancelEnrollmentRequest(requestId: string): Promise<void> {
|
||||
const headers = await getAuthHeaders();
|
||||
await axios.post(`${API_BASE}/database/timetable/enrollment-requests/${requestId}/cancel`, {}, { headers });
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Combined Export
|
||||
// ============================================================================
|
||||
|
||||
export const timetableService = {
|
||||
...classService,
|
||||
...timetableService,
|
||||
...lessonService,
|
||||
...enrollmentService,
|
||||
};
|
||||
|
||||
export default timetableService;
|
||||
547
src/stores/timetableStore.ts
Normal file
547
src/stores/timetableStore.ts
Normal file
@ -0,0 +1,547 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import {
|
||||
Class,
|
||||
ClassWithRelations,
|
||||
Timetable,
|
||||
TimetableWithRelations,
|
||||
TimetableLesson,
|
||||
Lesson,
|
||||
LessonWithRelations,
|
||||
EnrollmentRequest,
|
||||
EnrollmentRequestWithProfile,
|
||||
} from '../types/timetable.types';
|
||||
import { timetableService } from '../services/timetableService';
|
||||
|
||||
// ============================================================================
|
||||
// State Types
|
||||
// ============================================================================
|
||||
|
||||
interface TimetableState {
|
||||
// Classes
|
||||
classes: Class[];
|
||||
currentClass: ClassWithRelations | null;
|
||||
myClasses: Class[];
|
||||
myTeachingClasses: Class[];
|
||||
classesLoading: boolean;
|
||||
classesError: string | null;
|
||||
|
||||
// Timetables
|
||||
timetables: Timetable[];
|
||||
currentTimetable: TimetableWithRelations | null;
|
||||
timetablesLoading: boolean;
|
||||
timetablesError: string | null;
|
||||
|
||||
// Lessons
|
||||
lessons: Lesson[];
|
||||
currentLesson: LessonWithRelations | null;
|
||||
lessonsLoading: boolean;
|
||||
lessonsError: string | null;
|
||||
|
||||
// Enrollment Requests
|
||||
enrollmentRequests: EnrollmentRequestWithProfile[];
|
||||
enrollmentRequestsLoading: boolean;
|
||||
enrollmentRequestsError: string | null;
|
||||
|
||||
// Pagination
|
||||
totalCount: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
|
||||
// Filters
|
||||
filterSubject: string | null;
|
||||
filterSchoolYear: string | null;
|
||||
filterAcademicTerm: string | null;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
interface TimetableActions {
|
||||
// Class Actions
|
||||
fetchClasses: (params?: {
|
||||
subject?: string;
|
||||
schoolYear?: string;
|
||||
academicTerm?: string;
|
||||
search?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}) => Promise<void>;
|
||||
fetchClass: (classId: string) => Promise<void>;
|
||||
createClass: (data: {
|
||||
name: string;
|
||||
subject: string;
|
||||
schoolYear: string;
|
||||
academicTerm: string;
|
||||
description?: string;
|
||||
}) => Promise<Class>;
|
||||
updateClass: (classId: string, data: Partial<Class>) => Promise<void>;
|
||||
deleteClass: (classId: string) => Promise<void>;
|
||||
fetchMyClasses: () => Promise<void>;
|
||||
fetchMyTeachingClasses: () => Promise<void>;
|
||||
clearCurrentClass: () => void;
|
||||
|
||||
// Class Teacher Actions
|
||||
addTeacherToClass: (classId: string, teacherId: string, isPrimary?: boolean) => Promise<void>;
|
||||
removeTeacherFromClass: (classId: string, teacherId: string) => Promise<void>;
|
||||
|
||||
// Class Student Actions
|
||||
addStudentToClass: (classId: string, studentId: string) => Promise<void>;
|
||||
removeStudentFromClass: (classId: string, studentId: string) => Promise<void>;
|
||||
|
||||
// Enrollment Request Actions
|
||||
fetchEnrollmentRequests: (classId: string) => Promise<void>;
|
||||
requestEnrollment: (classId: string, message?: string) => Promise<void>;
|
||||
respondToEnrollmentRequest: (requestId: string, status: 'approved' | 'rejected', responseMessage?: string) => Promise<void>;
|
||||
|
||||
// Timetable Actions
|
||||
fetchTimetables: (params?: {
|
||||
classId?: string;
|
||||
type?: 'adhoc' | 'recurring';
|
||||
isActive?: boolean;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}) => Promise<void>;
|
||||
fetchTimetable: (timetableId: string) => Promise<void>;
|
||||
createTimetable: (data: {
|
||||
classId: string;
|
||||
name: string;
|
||||
type: 'adhoc' | 'recurring';
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
recurrenceRule?: string;
|
||||
}) => Promise<Timetable>;
|
||||
updateTimetable: (timetableId: string, data: Partial<Timetable>) => Promise<void>;
|
||||
deleteTimetable: (timetableId: string) => Promise<void>;
|
||||
fetchMyTimetables: () => Promise<void>;
|
||||
fetchMyTeachingTimetables: () => Promise<void>;
|
||||
clearCurrentTimetable: () => void;
|
||||
|
||||
// Timetable Teacher Actions
|
||||
addTeacherToTimetable: (timetableId: string, teacherId: string, isPrimary?: boolean) => Promise<void>;
|
||||
removeTeacherFromTimetable: (timetableId: string, teacherId: string) => Promise<void>;
|
||||
|
||||
// Timetable Lesson Actions
|
||||
addTimetableLesson: (timetableId: string, data: {
|
||||
dayOfWeek: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
room?: string;
|
||||
maxStudents?: number;
|
||||
}) => Promise<void>;
|
||||
updateTimetableLesson: (timetableId: string, lessonId: string, data: Partial<TimetableLesson>) => Promise<void>;
|
||||
removeTimetableLesson: (timetableId: string, lessonId: string) => Promise<void>;
|
||||
|
||||
// Lesson Actions
|
||||
fetchLessons: (params?: {
|
||||
timetableId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
status?: 'scheduled' | 'completed' | 'cancelled';
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}) => Promise<void>;
|
||||
fetchLesson: (lessonId: string) => Promise<void>;
|
||||
generateLessons: (timetableId: string, startDate: string, endDate: string) => Promise<void>;
|
||||
cancelLesson: (lessonId: string, reason?: string) => Promise<void>;
|
||||
clearCurrentLesson: () => void;
|
||||
|
||||
// Filter Actions
|
||||
setFilterSubject: (subject: string | null) => void;
|
||||
setFilterSchoolYear: (schoolYear: string | null) => void;
|
||||
setFilterAcademicTerm: (academicTerm: string | null) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setPage: (page: number) => void;
|
||||
setPageSize: (size: number) => void;
|
||||
clearFilters: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Initial State
|
||||
// ============================================================================
|
||||
|
||||
const initialState: TimetableState = {
|
||||
classes: [],
|
||||
currentClass: null,
|
||||
myClasses: [],
|
||||
myTeachingClasses: [],
|
||||
classesLoading: false,
|
||||
classesError: null,
|
||||
|
||||
timetables: [],
|
||||
currentTimetable: null,
|
||||
timetablesLoading: false,
|
||||
timetablesError: null,
|
||||
|
||||
lessons: [],
|
||||
currentLesson: null,
|
||||
lessonsLoading: false,
|
||||
lessonsError: null,
|
||||
|
||||
enrollmentRequests: [],
|
||||
enrollmentRequestsLoading: false,
|
||||
enrollmentRequestsError: null,
|
||||
|
||||
totalCount: 0,
|
||||
currentPage: 0,
|
||||
pageSize: 20,
|
||||
|
||||
filterSubject: null,
|
||||
filterSchoolYear: null,
|
||||
filterAcademicTerm: null,
|
||||
searchQuery: '',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Store
|
||||
// ============================================================================
|
||||
|
||||
const useTimetableStore = create<TimetableState & TimetableActions>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// ============================================================================
|
||||
// Class Actions
|
||||
// ============================================================================
|
||||
|
||||
fetchClasses: async (params = {}) => {
|
||||
set({ classesLoading: true, classesError: null });
|
||||
try {
|
||||
const response = await timetableService.getClasses(params);
|
||||
set({
|
||||
classes: response.items,
|
||||
totalCount: response.total,
|
||||
classesLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch classes:', error);
|
||||
set({
|
||||
classesError: error instanceof Error ? error.message : 'Failed to fetch classes',
|
||||
classesLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
fetchClass: async (classId: string) => {
|
||||
set({ classesLoading: true, classesError: null });
|
||||
try {
|
||||
const classData = await timetableService.getClass(classId);
|
||||
set({ currentClass: classData, classesLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch class:', error);
|
||||
set({
|
||||
classesError: error instanceof Error ? error.message : 'Failed to fetch class',
|
||||
classesLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
createClass: async (data) => {
|
||||
const newClass = await timetableService.createClass(data);
|
||||
set((state) => ({
|
||||
classes: [newClass, ...state.classes],
|
||||
}));
|
||||
return newClass;
|
||||
},
|
||||
|
||||
updateClass: async (classId, data) => {
|
||||
const updatedClass = await timetableService.updateClass(classId, data);
|
||||
set((state) => ({
|
||||
classes: state.classes.map((c) => (c.id === classId ? updatedClass : c)),
|
||||
currentClass: state.currentClass?.id === classId ? { ...state.currentClass, ...updatedClass } : state.currentClass,
|
||||
}));
|
||||
},
|
||||
|
||||
deleteClass: async (classId) => {
|
||||
await timetableService.deleteClass(classId);
|
||||
set((state) => ({
|
||||
classes: state.classes.filter((c) => c.id !== classId),
|
||||
currentClass: state.currentClass?.id === classId ? null : state.currentClass,
|
||||
}));
|
||||
},
|
||||
|
||||
fetchMyClasses: async () => {
|
||||
set({ classesLoading: true, classesError: null });
|
||||
try {
|
||||
const classes = await timetableService.getMyClasses();
|
||||
set({ myClasses: classes, classesLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch my classes:', error);
|
||||
set({
|
||||
classesError: error instanceof Error ? error.message : 'Failed to fetch my classes',
|
||||
classesLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
fetchMyTeachingClasses: async () => {
|
||||
set({ classesLoading: true, classesError: null });
|
||||
try {
|
||||
const classes = await timetableService.getMyTeachingClasses();
|
||||
set({ myTeachingClasses: classes, classesLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch my teaching classes:', error);
|
||||
set({
|
||||
classesError: error instanceof Error ? error.message : 'Failed to fetch my teaching classes',
|
||||
classesLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
clearCurrentClass: () => set({ currentClass: null }),
|
||||
|
||||
addTeacherToClass: async (classId, teacherId, isPrimary = false) => {
|
||||
await timetableService.addTeacherToClass(classId, teacherId, isPrimary);
|
||||
await get().fetchClass(classId);
|
||||
},
|
||||
|
||||
removeTeacherFromClass: async (classId, teacherId) => {
|
||||
await timetableService.removeTeacherFromClass(classId, teacherId);
|
||||
await get().fetchClass(classId);
|
||||
},
|
||||
|
||||
addStudentToClass: async (classId, studentId) => {
|
||||
await timetableService.addStudentToClass(classId, studentId);
|
||||
await get().fetchClass(classId);
|
||||
},
|
||||
|
||||
removeStudentFromClass: async (classId, studentId) => {
|
||||
await timetableService.removeStudentFromClass(classId, studentId);
|
||||
await get().fetchClass(classId);
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Enrollment Request Actions
|
||||
// ============================================================================
|
||||
|
||||
fetchEnrollmentRequests: async (classId) => {
|
||||
set({ enrollmentRequestsLoading: true, enrollmentRequestsError: null });
|
||||
try {
|
||||
const requests = await timetableService.getEnrollmentRequests(classId);
|
||||
set({ enrollmentRequests: requests, enrollmentRequestsLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch enrollment requests:', error);
|
||||
set({
|
||||
enrollmentRequestsError: error instanceof Error ? error.message : 'Failed to fetch enrollment requests',
|
||||
enrollmentRequestsLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
requestEnrollment: async (classId, message) => {
|
||||
await timetableService.requestEnrollment(classId, message);
|
||||
},
|
||||
|
||||
respondToEnrollmentRequest: async (requestId, status, responseMessage) => {
|
||||
await timetableService.respondToEnrollmentRequest(requestId, status, responseMessage);
|
||||
await get().fetchEnrollmentRequests(get().currentClass?.id || '');
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Timetable Actions
|
||||
// ============================================================================
|
||||
|
||||
fetchTimetables: async (params = {}) => {
|
||||
set({ timetablesLoading: true, timetablesError: null });
|
||||
try {
|
||||
const response = await timetableService.getTimetables(params);
|
||||
set({
|
||||
timetables: response.items,
|
||||
totalCount: response.total,
|
||||
timetablesLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch timetables:', error);
|
||||
set({
|
||||
timetablesError: error instanceof Error ? error.message : 'Failed to fetch timetables',
|
||||
timetablesLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
fetchTimetable: async (timetableId) => {
|
||||
set({ timetablesLoading: true, timetablesError: null });
|
||||
try {
|
||||
const timetable = await timetableService.getTimetable(timetableId);
|
||||
set({ currentTimetable: timetable, timetablesLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch timetable:', error);
|
||||
set({
|
||||
timetablesError: error instanceof Error ? error.message : 'Failed to fetch timetable',
|
||||
timetablesLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
createTimetable: async (data) => {
|
||||
const newTimetable = await timetableService.createTimetable(data);
|
||||
set((state) => ({
|
||||
timetables: [newTimetable, ...state.timetables],
|
||||
}));
|
||||
return newTimetable;
|
||||
},
|
||||
|
||||
updateTimetable: async (timetableId, data) => {
|
||||
const updatedTimetable = await timetableService.updateTimetable(timetableId, data);
|
||||
set((state) => ({
|
||||
timetables: state.timetables.map((t) => (t.id === timetableId ? updatedTimetable : t)),
|
||||
currentTimetable: state.currentTimetable?.id === timetableId ? { ...state.currentTimetable, ...updatedTimetable } : state.currentTimetable,
|
||||
}));
|
||||
},
|
||||
|
||||
deleteTimetable: async (timetableId) => {
|
||||
await timetableService.deleteTimetable(timetableId);
|
||||
set((state) => ({
|
||||
timetables: state.timetables.filter((t) => t.id !== timetableId),
|
||||
currentTimetable: state.currentTimetable?.id === timetableId ? null : state.currentTimetable,
|
||||
}));
|
||||
},
|
||||
|
||||
fetchMyTimetables: async () => {
|
||||
set({ timetablesLoading: true, timetablesError: null });
|
||||
try {
|
||||
const timetables = await timetableService.getMyTimetables();
|
||||
set({ timetables, timetablesLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch my timetables:', error);
|
||||
set({
|
||||
timetablesError: error instanceof Error ? error.message : 'Failed to fetch my timetables',
|
||||
timetablesLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
fetchMyTeachingTimetables: async () => {
|
||||
set({ timetablesLoading: true, timetablesError: null });
|
||||
try {
|
||||
const timetables = await timetableService.getMyTeachingTimetables();
|
||||
set({ timetables, timetablesLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch my teaching timetables:', error);
|
||||
set({
|
||||
timetablesError: error instanceof Error ? error.message : 'Failed to fetch my teaching timetables',
|
||||
timetablesLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
clearCurrentTimetable: () => set({ currentTimetable: null }),
|
||||
|
||||
addTeacherToTimetable: async (timetableId, teacherId, isPrimary = false) => {
|
||||
await timetableService.addTeacherToTimetable(timetableId, teacherId, isPrimary);
|
||||
await get().fetchTimetable(timetableId);
|
||||
},
|
||||
|
||||
removeTeacherFromTimetable: async (timetableId, teacherId) => {
|
||||
await timetableService.removeTeacherFromTimetable(timetableId, teacherId);
|
||||
await get().fetchTimetable(timetableId);
|
||||
},
|
||||
|
||||
addTimetableLesson: async (timetableId, data) => {
|
||||
await timetableService.addTimetableLesson(timetableId, data);
|
||||
await get().fetchTimetable(timetableId);
|
||||
},
|
||||
|
||||
updateTimetableLesson: async (timetableId, lessonId, data) => {
|
||||
await timetableService.updateTimetableLesson(timetableId, lessonId, data);
|
||||
await get().fetchTimetable(timetableId);
|
||||
},
|
||||
|
||||
removeTimetableLesson: async (timetableId, lessonId) => {
|
||||
await timetableService.removeTimetableLesson(timetableId, lessonId);
|
||||
await get().fetchTimetable(timetableId);
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Lesson Actions
|
||||
// ============================================================================
|
||||
|
||||
fetchLessons: async (params = {}) => {
|
||||
set({ lessonsLoading: true, lessonsError: null });
|
||||
try {
|
||||
const response = await timetableService.getLessons(params);
|
||||
set({
|
||||
lessons: response.items,
|
||||
totalCount: response.total,
|
||||
lessonsLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch lessons:', error);
|
||||
set({
|
||||
lessonsError: error instanceof Error ? error.message : 'Failed to fetch lessons',
|
||||
lessonsLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
fetchLesson: async (lessonId) => {
|
||||
set({ lessonsLoading: true, lessonsError: null });
|
||||
try {
|
||||
const lesson = await timetableService.getLesson(lessonId);
|
||||
set({ currentLesson: lesson, lessonsLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch lesson:', error);
|
||||
set({
|
||||
lessonsError: error instanceof Error ? error.message : 'Failed to fetch lesson',
|
||||
lessonsLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
generateLessons: async (timetableId, startDate, endDate) => {
|
||||
set({ lessonsLoading: true, lessonsError: null });
|
||||
try {
|
||||
await timetableService.generateLessons(timetableId, startDate, endDate);
|
||||
await get().fetchLessons({ timetableId });
|
||||
set({ lessonsLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to generate lessons:', error);
|
||||
set({
|
||||
lessonsError: error instanceof Error ? error.message : 'Failed to generate lessons',
|
||||
lessonsLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
cancelLesson: async (lessonId, reason) => {
|
||||
await timetableService.cancelLesson(lessonId, reason);
|
||||
await get().fetchLesson(lessonId);
|
||||
},
|
||||
|
||||
clearCurrentLesson: () => set({ currentLesson: null }),
|
||||
|
||||
// ============================================================================
|
||||
// Filter Actions
|
||||
// ============================================================================
|
||||
|
||||
setFilterSubject: (subject) => set({ filterSubject: subject, currentPage: 0 }),
|
||||
setFilterSchoolYear: (schoolYear) => set({ filterSchoolYear: schoolYear, currentPage: 0 }),
|
||||
setFilterAcademicTerm: (academicTerm) => set({ filterAcademicTerm: academicTerm, currentPage: 0 }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query, currentPage: 0 }),
|
||||
setPage: (page) => set({ currentPage: page }),
|
||||
setPageSize: (size) => set({ pageSize: size, currentPage: 0 }),
|
||||
|
||||
clearFilters: () =>
|
||||
set({
|
||||
filterSubject: null,
|
||||
filterSchoolYear: null,
|
||||
filterAcademicTerm: null,
|
||||
searchQuery: '',
|
||||
currentPage: 0,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'timetable-store',
|
||||
partialize: (state) => ({
|
||||
filterSubject: state.filterSubject,
|
||||
filterSchoolYear: state.filterSchoolYear,
|
||||
filterAcademicTerm: state.filterAcademicTerm,
|
||||
pageSize: state.pageSize,
|
||||
}),
|
||||
}
|
||||
),
|
||||
{ name: 'TimetableStore' }
|
||||
)
|
||||
);
|
||||
|
||||
export default useTimetableStore;
|
||||
262
src/types/timetable.types.ts
Normal file
262
src/types/timetable.types.ts
Normal file
@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Timetable System Type Definitions
|
||||
* Comprehensive types for classes, timetables, lessons, and enrollment
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Enums
|
||||
// ============================================================================
|
||||
|
||||
export type EnrollmentStatus = 'pending' | 'approved' | 'rejected';
|
||||
export type LessonStatus = 'scheduled' | 'completed' | 'cancelled';
|
||||
export type TimetableType = 'adhoc' | 'recurring';
|
||||
export type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Sunday
|
||||
|
||||
// ============================================================================
|
||||
// Base Interfaces
|
||||
// ============================================================================
|
||||
|
||||
export interface Class {
|
||||
id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
school_year: string;
|
||||
academic_term: string;
|
||||
description?: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface ClassTeacher {
|
||||
id: string;
|
||||
class_id: string;
|
||||
teacher_id: string;
|
||||
is_primary: boolean;
|
||||
assigned_at: string;
|
||||
}
|
||||
|
||||
export interface ClassStudent {
|
||||
id: string;
|
||||
class_id: string;
|
||||
student_id: string;
|
||||
enrolled_at: string;
|
||||
}
|
||||
|
||||
export interface Timetable {
|
||||
id: string;
|
||||
class_id: string;
|
||||
name: string;
|
||||
type: TimetableType;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
recurrence_rule?: string;
|
||||
is_active: boolean;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TimetableTeacher {
|
||||
id: string;
|
||||
timetable_id: string;
|
||||
teacher_id: string;
|
||||
is_primary: boolean;
|
||||
assigned_at: string;
|
||||
}
|
||||
|
||||
export interface TimetableLesson {
|
||||
id: string;
|
||||
timetable_id: string;
|
||||
day_of_week: DayOfWeek;
|
||||
start_time: string; // HH:MM format
|
||||
end_time: string; // HH:MM format
|
||||
room?: string;
|
||||
max_students?: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Lesson {
|
||||
id: string;
|
||||
timetable_id: string;
|
||||
lesson_definition_id: string;
|
||||
scheduled_date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
room?: string;
|
||||
status: LessonStatus;
|
||||
cancellation_reason?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LessonWhiteboard {
|
||||
id: string;
|
||||
lesson_id: string;
|
||||
whiteboard_id: string;
|
||||
student_id?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface EnrollmentRequest {
|
||||
id: string;
|
||||
class_id: string;
|
||||
student_id: string;
|
||||
status: EnrollmentStatus;
|
||||
request_message?: string;
|
||||
response_message?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Profile Interfaces (for joins)
|
||||
// ============================================================================
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name?: string;
|
||||
avatar_url?: string;
|
||||
role: 'teacher' | 'student' | 'admin';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Extended Interfaces (with relations)
|
||||
// ============================================================================
|
||||
|
||||
export interface ClassWithRelations extends Class {
|
||||
teachers?: Array<ClassTeacher & { profile: UserProfile }>;
|
||||
students?: Array<ClassStudent & { profile: UserProfile }>;
|
||||
timetables?: Timetable[];
|
||||
enrollment_requests?: Array<EnrollmentRequestWithProfile>;
|
||||
student_count?: number;
|
||||
}
|
||||
|
||||
export interface TimetableWithRelations extends Timetable {
|
||||
class?: Class;
|
||||
teachers?: Array<TimetableTeacher & { profile: UserProfile }>;
|
||||
lessons?: TimetableLesson[];
|
||||
lesson_instances?: Lesson[];
|
||||
}
|
||||
|
||||
export interface LessonWithRelations extends Lesson {
|
||||
timetable?: TimetableWithRelations;
|
||||
whiteboards?: Array<LessonWhiteboard & { whiteboard: { id: string; name: string } }>;
|
||||
}
|
||||
|
||||
export interface EnrollmentRequestWithProfile extends EnrollmentRequest {
|
||||
student?: UserProfile;
|
||||
class?: Class;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Request/Response Types
|
||||
// ============================================================================
|
||||
|
||||
export interface CreateClassRequest {
|
||||
name: string;
|
||||
subject: string;
|
||||
school_year: string;
|
||||
academic_term: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateClassRequest {
|
||||
name?: string;
|
||||
subject?: string;
|
||||
school_year?: string;
|
||||
academic_term?: string;
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateTimetableRequest {
|
||||
class_id: string;
|
||||
name: string;
|
||||
type: TimetableType;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
recurrence_rule?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTimetableRequest {
|
||||
name?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
recurrence_rule?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateTimetableLessonRequest {
|
||||
day_of_week: DayOfWeek;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
room?: string;
|
||||
max_students?: number;
|
||||
}
|
||||
|
||||
export interface UpdateTimetableLessonRequest {
|
||||
day_of_week?: DayOfWeek;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
room?: string;
|
||||
max_students?: number;
|
||||
}
|
||||
|
||||
export interface GenerateLessonsRequest {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
}
|
||||
|
||||
export interface CancelLessonRequest {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface EnrollmentResponseRequest {
|
||||
status: 'approved' | 'rejected';
|
||||
response_message?: string;
|
||||
}
|
||||
|
||||
export interface CreateEnrollmentRequest {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Filter and Pagination Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ClassFilters {
|
||||
subject?: string;
|
||||
school_year?: string;
|
||||
academic_term?: string;
|
||||
search?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface TimetableFilters {
|
||||
class_id?: string;
|
||||
type?: TimetableType;
|
||||
is_active?: boolean;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface LessonFilters {
|
||||
timetable_id?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
status?: LessonStatus;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user