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:
Agent Zero
2026-02-26 03:27:46 +00:00
parent d5c53f2c17
commit 11c139b410
12 changed files with 2867 additions and 0 deletions
@@ -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;