- 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
548 lines
20 KiB
TypeScript
548 lines
20 KiB
TypeScript
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;
|