app/src/stores/timetableStore.ts
Agent Zero 11c139b410 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
2026-02-26 03:27:46 +00:00

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;