feat(timetable): add page components, services, stores and types
- Add timetable page components: - ClassesListPage.tsx (browse and search classes) - MyClassesPage.tsx (student enrolled classes) - EnrollmentRequestsPage.tsx (teacher approval interface) - TimetablePage.tsx (weekly schedule view) - LessonViewPage.tsx (TLDraw-integrated lesson view) - Add timetableService.ts for API communication - Add timetableStore.ts for state management - Add timetable.types.ts for TypeScript definitions - Add common components (LoadingSpinner, ErrorMessage, EmptyState) - Add .env.development with local development configuration
This commit is contained in:
@@ -0,0 +1,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;
|
||||
Reference in New Issue
Block a user