From 0058decaefa47a847a797ce214bebf883e7ca12a Mon Sep 17 00:00:00 2001 From: ssbg04 Date: Sun, 4 Oct 2026 11:24:07 +0800 Subject: [PATCH] feat: enhance teacher permissions, folder filtering, and bulk enrollment verification --- backend/src/controllers/documentController.js | 17 +- backend/src/controllers/folderController.js | 63 +++- backend/src/controllers/studentController.js | 63 +++- backend/src/routes/students.js | 1 + .../repositories/document_repository.dart | 6 + .../repositories/student_repository.dart | 48 +++ .../lib/ui/providers/document_provider.dart | 13 +- .../ui/screens/archives/archives_screen.dart | 15 +- .../screens/documents/documents_screen.dart | 127 +++++-- .../widgets/folder_filter_dialog.dart | 346 ++++++++++++++++++ .../ui/screens/students/students_screen.dart | 64 +++- .../widgets/bulk_enrollment_modal.dart | 309 ++++++++++++++++ .../students/widgets/edit_student_modal.dart | 55 +-- frontend/pubspec.lock | 4 +- frontend/pubspec.yaml | 2 +- 15 files changed, 1050 insertions(+), 83 deletions(-) create mode 100644 frontend/lib/ui/screens/documents/widgets/folder_filter_dialog.dart create mode 100644 frontend/lib/ui/screens/students/widgets/bulk_enrollment_modal.dart diff --git a/backend/src/controllers/documentController.js b/backend/src/controllers/documentController.js index 6c60ea5..9c05408 100644 --- a/backend/src/controllers/documentController.js +++ b/backend/src/controllers/documentController.js @@ -470,14 +470,25 @@ exports.getAllDocuments = (req, res) => { } } - if (gradeLevel.trim()) { - conditions.push(`e.grade_level = ?`); - params.push(gradeLevel.trim()); + if (gradeLevel.trim()) { + const gradeMatch = gradeLevel.trim().match(/\d+/); + if (gradeMatch) { + conditions.push(`e.grade_level = ?`); + params.push(parseInt(gradeMatch[0])); + } } if (schoolYear.trim()) { conditions.push(`ay.year_range = ?`); params.push(schoolYear.trim()); + } else { + conditions.push(`LOWER(ay.status) = ?`); + params.push('active'); + } + + if (false) { + conditions.push(`ay.year_range = ?`); + params.push(schoolYear.trim()); } if (studentId.trim()) { diff --git a/backend/src/controllers/folderController.js b/backend/src/controllers/folderController.js index fc9c810..258caef 100644 --- a/backend/src/controllers/folderController.js +++ b/backend/src/controllers/folderController.js @@ -14,7 +14,7 @@ const sanitizeFolderName = (str) => // GET /api/folders — list folders (manual + auto-created) // ============================================================ exports.getFolders = (req, res) => { - const { studentId, parentId, search = '' } = req.query; + const { studentId, parentId, search = '', gradeLevel = '', section = '', academicYear = '' } = req.query; const isTeacher = req.user?.role?.toLowerCase() === 'teacher'; const teacherId = req.user?.id; @@ -30,11 +30,13 @@ exports.getFolders = (req, res) => { `).run(); const conditions = []; - const params = []; + const whereParams = []; + const enrollmentParams = []; + const teacherParams = []; if (studentId) { conditions.push('f.student_id = ?'); - params.push(studentId); + whereParams.push(studentId); } if (parentId !== undefined) { @@ -42,18 +44,63 @@ exports.getFolders = (req, res) => { conditions.push('f.parent_id IS NULL'); } else { conditions.push('f.parent_id = ?'); - params.push(parseInt(parentId)); + whereParams.push(parseInt(parentId)); } + } else if (!studentId) { + conditions.push('f.parent_id IS NULL'); } if (search.trim()) { conditions.push('f.name LIKE ?'); - params.push(`%${search.trim().split('').join('%' )}%`); + whereParams.push(`%${search.trim().split('').join('%')}%`); } // Only show folders for Enrolled students (or manual subfolders) conditions.push("(f.student_id IS NULL OR s.status = 'Enrolled')"); + // Resolve target academic year: explicitly requested or active academic year + let targetYearRange = academicYear.trim(); + if (!targetYearRange || targetYearRange === 'All Years') { + const activeAy = db.prepare("SELECT year_range FROM academic_years WHERE LOWER(status) = 'active' LIMIT 1").get(); + if (activeAy) { + targetYearRange = activeAy.year_range; + } + } + + let enrollmentJoin = ''; + if (gradeLevel.trim() || section.trim() || targetYearRange) { + enrollmentJoin = `JOIN ( + SELECT e1.student_id, e1.grade_level, sec.name as section_name + FROM enrollments e1 + JOIN academic_years ay1 ON e1.academic_year_id = ay1.id + LEFT JOIN sections sec ON e1.section_id = sec.id + WHERE e1.id = ( + SELECT e2.id + FROM enrollments e2 + JOIN academic_years ay2 ON e2.academic_year_id = ay2.id + WHERE e2.student_id = e1.student_id + ${targetYearRange ? 'AND ay2.year_range = ?' : "AND LOWER(ay2.status) = 'active'"} + ORDER BY e2.id DESC + LIMIT 1 + ) + ) latest_enr ON latest_enr.student_id = f.student_id`; + if (targetYearRange) { + enrollmentParams.push(targetYearRange); + } + + if (gradeLevel.trim()) { + const gradeMatch = gradeLevel.match(/\d+/); + if (gradeMatch) { + conditions.push('latest_enr.grade_level = ?'); + whereParams.push(parseInt(gradeMatch[0])); + } + } + if (section.trim()) { + conditions.push('latest_enr.section_name = ?'); + whereParams.push(section.trim()); + } + } + const whereClause = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; // When the caller is a teacher, restrict to folders for students in @@ -65,7 +112,7 @@ exports.getFolders = (req, res) => { JOIN teacher_sections ts_sub ON e_sub.section_id = ts_sub.section_id WHERE ts_sub.teacher_id = ? ) ts_scope ON ts_scope.student_id = f.student_id`; - params.unshift(teacherId); + teacherParams.push(teacherId); } const sql = ` @@ -101,6 +148,7 @@ exports.getFolders = (req, res) => { ) as shs_completed FROM document_folders f ${teacherJoinSql} + ${enrollmentJoin} LEFT JOIN students s ON f.student_id = s.id LEFT JOIN users u ON f.created_by = u.id LEFT JOIN deleted_users_history dh ON f.created_by = dh.deleted_user_id @@ -108,7 +156,8 @@ exports.getFolders = (req, res) => { ORDER BY f.name ASC `; - const folders = db.prepare(sql).all(params); + const finalParams = [...teacherParams, ...enrollmentParams, ...whereParams]; + const folders = db.prepare(sql).all(...finalParams); res.json(folders); } catch (error) { console.error('getFolders error:', error); diff --git a/backend/src/controllers/studentController.js b/backend/src/controllers/studentController.js index 3123705..7daa2fb 100644 --- a/backend/src/controllers/studentController.js +++ b/backend/src/controllers/studentController.js @@ -255,8 +255,8 @@ exports.getAllStudents = (req, res) => { SELECT e.id FROM enrollments e JOIN academic_years ay_inner ON e.academic_year_id = ay_inner.id WHERE e.student_id = s.id - ${filterYearRange ? 'AND ay_inner.year_range = ?' : ''} - ORDER BY e.grade_level DESC, ay_inner.year_range DESC, e.id DESC LIMIT 1 + ${filterYearRange ? 'AND ay_inner.year_range = ?' : 'AND LOWER(ay_inner.status) = \'active\''} + ORDER BY e.grade_level DESC, e.id DESC LIMIT 1 ) LEFT JOIN sections sec ON sec.id = e_latest.section_id JOIN academic_years ay ON ay.id = e_latest.academic_year_id`; @@ -265,9 +265,12 @@ exports.getAllStudents = (req, res) => { } } - if (gradeLevel.trim()) { - conditions.push(`e_latest.grade_level = ?`); - whereParams.push(parseInt(gradeLevel)); + if (gradeLevel.trim()) { + const gradeMatch = gradeLevel.trim().match(/\d+/); + if (gradeMatch) { + conditions.push(`e_latest.grade_level = ?`); + whereParams.push(parseInt(gradeMatch[0])); + } } if (section.trim()) { conditions.push(`sec.name = ?`); @@ -796,6 +799,56 @@ exports.deleteStudent = (req, res) => { } }; +// ============================================================ +// POST /api/students/verify-lrns +// ============================================================ +exports.verifyLrns = (req, res) => { + const { lrns } = req.body; + if (!Array.isArray(lrns)) { + return res.status(400).json({ message: 'lrns must be an array' }); + } + + try { + const uniqueLrns = [...new Set(lrns.map(l => String(l).trim()).filter(l => l.length > 0))]; + if (uniqueLrns.length === 0) { + return res.json({ verified: [] }); + } + + const placeholders = uniqueLrns.map(() => '?').join(','); + const foundStudents = db.prepare(` + SELECT id, lrn, first_name, last_name, status + FROM students + WHERE lrn IN (${placeholders}) + `).all(...uniqueLrns); + + const foundMap = new Map(foundStudents.map(s => [s.lrn, s])); + + const verified = uniqueLrns.map(lrn => { + const st = foundMap.get(lrn); + if (st) { + return { + lrn, + studentId: st.id, + firstName: st.first_name, + lastName: st.last_name, + status: st.status, + found: true + }; + } else { + return { + lrn, + found: false + }; + } + }); + + res.json({ verified }); + } catch (error) { + console.error('verifyLrns error:', error); + res.status(500).json({ message: 'Failed to verify LRNs', error: error.message }); + } +}; + // ============================================================ // POST /api/students/bulk-enroll // ============================================================ diff --git a/backend/src/routes/students.js b/backend/src/routes/students.js index 334201a..fddce26 100644 --- a/backend/src/routes/students.js +++ b/backend/src/routes/students.js @@ -9,6 +9,7 @@ router.get('/:id', authenticateToken, studentController.getStudentById); // Create / Update / Delete restricted to super_admin & admin router.post('/', authenticateToken, authorizeRoles('admin'), studentController.createStudent); +router.post('/verify-lrns', authenticateToken, authorizeRoles('admin'), studentController.verifyLrns); router.post('/bulk-enroll', authenticateToken, authorizeRoles('admin'), studentController.bulkEnrollStudents); router.post('/bulk-ocr-import', authenticateToken, authorizeRoles('admin'), studentController.bulkCreateStudents); router.put('/bulk-graduate', authenticateToken, authorizeRoles('admin'), studentController.bulkGraduate); diff --git a/frontend/lib/domain/repositories/document_repository.dart b/frontend/lib/domain/repositories/document_repository.dart index c068f21..cc7ebe5 100644 --- a/frontend/lib/domain/repositories/document_repository.dart +++ b/frontend/lib/domain/repositories/document_repository.dart @@ -574,6 +574,9 @@ class DocumentRepository { int? studentId, int? parentId, String search = '', + String? academicYear, + String? gradeLevel, + String? section, }) async { try { final options = await _getAuthOptions(); @@ -583,6 +586,9 @@ class DocumentRepository { if (studentId != null) 'studentId': studentId, if (parentId != null) 'parentId': parentId.toString(), if (search.trim().isNotEmpty) 'search': search.trim(), + if (academicYear != null) 'academicYear': academicYear, + if (gradeLevel != null) 'gradeLevel': gradeLevel, + if (section != null) 'section': section, }, options: options, ); diff --git a/frontend/lib/domain/repositories/student_repository.dart b/frontend/lib/domain/repositories/student_repository.dart index 32f0c73..8aa7114 100644 --- a/frontend/lib/domain/repositories/student_repository.dart +++ b/frontend/lib/domain/repositories/student_repository.dart @@ -448,6 +448,54 @@ class StudentRepository { } } + // ---------------------------------------------------------------- + // Bulk Enroll Students + // ---------------------------------------------------------------- + Future bulkEnrollStudents({ + required List studentIds, + required int academicYearId, + required int gradeLevel, + required int sectionId, + String? trackStrand, + }) async { + try { + final options = await _getAuthOptions(); + await _dio.post( + '/students/bulk-enroll', + data: { + 'studentIds': studentIds, + 'academicYearId': academicYearId, + 'gradeLevel': gradeLevel, + 'sectionId': sectionId, + 'trackStrand': trackStrand, + }, + options: options, + ); + } on DioException catch (e) { + final msg = e.response?.data?['message'] ?? 'Failed to bulk enroll students.'; + throw Exception(msg); + } + } + + // ---------------------------------------------------------------- + // Verify LRNs + // ---------------------------------------------------------------- + Future>> verifyLrns(List lrns) async { + try { + final options = await _getAuthOptions(); + final response = await _dio.post( + '/students/verify-lrns', + data: {'lrns': lrns}, + options: options, + ); + final List verified = response.data['verified'] ?? []; + return verified.map((v) => v as Map).toList(); + } on DioException catch (e) { + final msg = e.response?.data?['message'] ?? 'Failed to verify LRNs.'; + throw Exception(msg); + } + } + // ---------------------------------------------------------------- // Bulk Create Students from OCR Import // ---------------------------------------------------------------- diff --git a/frontend/lib/ui/providers/document_provider.dart b/frontend/lib/ui/providers/document_provider.dart index 97da39c..a90a9a7 100644 --- a/frontend/lib/ui/providers/document_provider.dart +++ b/frontend/lib/ui/providers/document_provider.dart @@ -20,6 +20,7 @@ class DocumentQueryParams { final String documentType; final String gradeLevel; final String schoolYear; + final String section; // Optional: filter by specific student (for "Open Documents Folder" redirect) final int? studentId; @@ -31,6 +32,7 @@ class DocumentQueryParams { this.documentType = 'All Types', this.gradeLevel = '', this.schoolYear = '', + this.section = '', this.studentId, }); @@ -42,6 +44,7 @@ class DocumentQueryParams { String? documentType, String? gradeLevel, String? schoolYear, + String? section, int? studentId, bool clearStudentId = false, }) { @@ -53,6 +56,7 @@ class DocumentQueryParams { documentType: documentType ?? this.documentType, gradeLevel: gradeLevel ?? this.gradeLevel, schoolYear: schoolYear ?? this.schoolYear, + section: section ?? this.section, studentId: clearStudentId ? null : (studentId ?? this.studentId), ); } @@ -100,6 +104,8 @@ class DocumentQueryNotifier extends Notifier { state = state.copyWith(gradeLevel: gradeLevel, page: 1); void setSchoolYear(String schoolYear) => state = state.copyWith(schoolYear: schoolYear, page: 1); + void setSection(String section) => + state = state.copyWith(section: section, page: 1); /// Navigate to a specific student's documents void setStudentId(int? studentId) { @@ -153,7 +159,12 @@ final studentFoldersProvider = FutureProvider.autoDispose>(( ) async { final query = ref.watch(documentQueryProvider); final repo = ref.read(documentRepositoryProvider); - return repo.getFolders(search: query.search); + return repo.getFolders( + search: query.search, + academicYear: query.schoolYear.isNotEmpty && query.schoolYear != 'All Years' ? query.schoolYear : null, + gradeLevel: query.gradeLevel.isNotEmpty && query.gradeLevel != 'All Grades' ? query.gradeLevel : null, + section: query.section.isNotEmpty && query.section != 'All Sections' ? query.section : null, + ); }); // ============================================================ diff --git a/frontend/lib/ui/screens/archives/archives_screen.dart b/frontend/lib/ui/screens/archives/archives_screen.dart index de10089..fab6f0f 100644 --- a/frontend/lib/ui/screens/archives/archives_screen.dart +++ b/frontend/lib/ui/screens/archives/archives_screen.dart @@ -2281,12 +2281,15 @@ class _ArchivesScreenState extends ConsumerState color: isDark ? AppColors.darkBorder : Colors.grey.shade300, ), const SizedBox(height: 16), - Text( - message, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 15, - color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 15, + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + ), ), ), ], diff --git a/frontend/lib/ui/screens/documents/documents_screen.dart b/frontend/lib/ui/screens/documents/documents_screen.dart index 62bf5e3..f27ed75 100644 --- a/frontend/lib/ui/screens/documents/documents_screen.dart +++ b/frontend/lib/ui/screens/documents/documents_screen.dart @@ -10,6 +10,8 @@ import '../../shared/inputs/app_search_bar.dart'; import '../../providers/document_provider.dart'; import '../../providers/student_provider.dart'; import '../../providers/auth_provider.dart'; +import '../../providers/setup_provider.dart'; +import '../../../domain/entities/setup_models.dart'; import '../../shared/dialogs/document_properties_dialog.dart'; import '../../shared/widgets/app_pagination.dart'; import '../../shared/widgets/app_error_state.dart'; @@ -32,6 +34,7 @@ import 'widgets/student_archives_modal.dart'; import 'widgets/bulk_operations_bar.dart'; import 'widgets/styled_folder_icon.dart'; import '../../../domain/entities/document_model.dart'; +import 'widgets/folder_filter_dialog.dart'; class DocumentsScreen extends ConsumerStatefulWidget { final String userRole; @@ -67,7 +70,7 @@ class _DocumentsScreenState extends ConsumerState String _selectedStatus = 'All Statuses'; String _selectedDocumentType = 'All Types'; String _selectedGradeLevel = 'All Grades'; - String _selectedSchoolYear = 'All Years'; + String _selectedSection = 'All Sections'; // Cached doc type lists for filter expansion List _jhsItems = []; @@ -105,8 +108,26 @@ class _DocumentsScreenState extends ConsumerState } }); - WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) return; + // Initialize active academic year in background for folder filtering + if (ref.read(documentQueryProvider).schoolYear.isEmpty) { + List years = + ref.read(academicYearsListProvider).asData?.value ?? []; + if (years.isEmpty) { + try { + years = await ref.read(academicYearsListProvider.future); + } catch (_) {} + } + final active = years.cast().firstWhere( + (y) => y?.status.toLowerCase() == 'active', + orElse: () => null, + ); + if (active != null && active.yearRange.isNotEmpty && mounted) { + ref.read(documentQueryProvider.notifier).setSchoolYear(active.yearRange); + } + } + // If a specific student was passed, jump to Folders tab and open that folder final initialFolder = ref.read(openedFolderProvider); if (initialFolder != null) { @@ -260,24 +281,49 @@ class _DocumentsScreenState extends ConsumerState n.setGradeLevel( _selectedGradeLevel == 'All Grades' ? '' : _selectedGradeLevel, ); - n.setSchoolYear( - _selectedSchoolYear == 'All Years' ? '' : _selectedSchoolYear, + n.setSection( + _selectedSection == 'All Sections' ? '' : _selectedSection, ); } void _clearFilters() { + final activeYear = ref.read(academicYearsListProvider).asData?.value.cast().firstWhere( + (y) => y?.status.toLowerCase() == 'active', + orElse: () => null, + ); + final activeYearRange = activeYear?.yearRange ?? ''; + setState(() { _foldersPage = 1; _selectedStatus = 'All Statuses'; _selectedDocumentType = 'All Types'; _selectedGradeLevel = 'All Grades'; - _selectedSchoolYear = 'All Years'; + _selectedSection = 'All Sections'; _searchController.clear(); }); ref.read(documentQueryProvider.notifier).reset(); + if (activeYearRange.isNotEmpty) { + ref.read(documentQueryProvider.notifier).setSchoolYear(activeYearRange); + } _applyFilters(); } + void _showFolderFilterDialog(BuildContext context) { + final currentQuery = ref.read(documentQueryProvider); + FolderFilterDialog.show(context, query: currentQuery).then((_) { + if (!mounted) return; + // Re-read query in case it was updated by the dialog + final updatedQuery = ref.read(documentQueryProvider); + setState(() { + _selectedGradeLevel = updatedQuery.gradeLevel.isEmpty ? 'All Grades' : updatedQuery.gradeLevel; + _selectedSection = updatedQuery.section.isEmpty ? 'All Sections' : updatedQuery.section; + }); + // The dialog already updates the notifier, so we just invalidate the providers + ref.invalidate(foldersProvider); + ref.invalidate(studentFoldersProvider); + }); + } + void _handleAction(String action, DocumentModel document) async { final documentId = document.id; final studentId = document.studentId; @@ -683,7 +729,7 @@ class _DocumentsScreenState extends ConsumerState final requirementsAsync = ref.watch(documentRequirementsProvider); final academicYearsAsync = ref.watch(academicYearsProvider); final statusesAsync = ref.watch(documentStatusesProvider); - final foldersAsync = ref.watch(foldersProvider); + final foldersAsync = ref.watch(studentFoldersProvider); final screenW = MediaQuery.of(context).size.width; final isMobile = screenW < 700; final isStudentFiltered = query.studentId != null; @@ -1234,6 +1280,32 @@ class _DocumentsScreenState extends ConsumerState const SizedBox(width: 2), ], + // Folder Filters (Academic Year, Grade Level, Section) + if (_tabController.index == 0 && !isFolderOpened) ...[ + Tooltip( + message: 'Folder Filters', + child: IconButton( + onPressed: () { + _showFolderFilterDialog(context); + }, + icon: Badge( + isLabelVisible: _getFolderFilterCount() > 0, + label: Text(_getFolderFilterCount().toString()), + child: Icon( + Icons.filter_list_rounded, + size: 20, + color: _getFolderFilterCount() > 0 + ? AppColors.primaryGreen + : (isDark ? AppColors.darkTextPrimary : Colors.black87), + ), + ), + padding: const EdgeInsets.all(8), + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ), + ), + const SizedBox(width: 2), + ], + // Filter button (Dropdown only for Document Type, between Search and Upload/Bulk Add) if (_tabController.index == 1 || isFolderOpened) ...[ PopupMenuButton( @@ -1460,11 +1532,17 @@ class _DocumentsScreenState extends ConsumerState ); } + int _getFolderFilterCount() { + int count = 0; + if (_selectedGradeLevel != 'All Grades' && _selectedGradeLevel.isNotEmpty) count++; + if (_selectedSection != 'All Sections' && _selectedSection.isNotEmpty) count++; + return count; + } + int _getActiveFilterCount() { int count = 0; if (_selectedDocumentType != 'All Types') count++; if (_selectedGradeLevel != 'All Grades') count++; - if (_selectedSchoolYear != 'All Years') count++; return count; } @@ -1768,13 +1846,16 @@ class _DocumentsScreenState extends ConsumerState ), ), const SizedBox(height: 8), - Text( - 'You have no sections assigned to your account yet.\nContact your administrator to assign sections.', - textAlign: TextAlign.center, - style: TextStyle( - color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, - fontSize: 13, - height: 1.5, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Text( + 'You have no sections assigned to your account yet.\nContact your administrator to assign sections.', + textAlign: TextAlign.center, + style: TextStyle( + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + fontSize: 13, + height: 1.5, + ), ), ), ], @@ -2430,13 +2511,16 @@ class _DocumentsScreenState extends ConsumerState ), ), const SizedBox(height: 8), - Text( - 'You have no sections assigned to your account yet.\nContact your administrator to assign sections.', - textAlign: TextAlign.center, - style: TextStyle( - color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, - fontSize: 13, - height: 1.5, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Text( + 'You have no sections assigned to your account yet.\nContact your administrator to assign sections.', + textAlign: TextAlign.center, + style: TextStyle( + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + fontSize: 13, + height: 1.5, + ), ), ), ], @@ -2468,7 +2552,6 @@ class _DocumentsScreenState extends ConsumerState ), if (_selectedDocumentType != 'All Types' || _selectedGradeLevel != 'All Grades' || - _selectedSchoolYear != 'All Years' || _searchController.text.isNotEmpty) ...[ const SizedBox(height: 16), TextButton.icon( diff --git a/frontend/lib/ui/screens/documents/widgets/folder_filter_dialog.dart b/frontend/lib/ui/screens/documents/widgets/folder_filter_dialog.dart new file mode 100644 index 0000000..eaab746 --- /dev/null +++ b/frontend/lib/ui/screens/documents/widgets/folder_filter_dialog.dart @@ -0,0 +1,346 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/constants/app_colors.dart'; +import '../../../../domain/entities/setup_models.dart'; +import '../../../providers/setup_provider.dart'; +import '../../../providers/document_provider.dart'; + +class FolderFilterDialog extends ConsumerStatefulWidget { + final DocumentQueryParams initialQuery; + + const FolderFilterDialog({ + super.key, + required this.initialQuery, + }); + + static Future show( + BuildContext context, { + required DocumentQueryParams query, + }) { + return showDialog( + context: context, + barrierColor: Colors.black.withValues(alpha: 0.35), + builder: (ctx) => Dialog( + backgroundColor: Colors.transparent, + insetPadding: EdgeInsets.symmetric( + horizontal: 20, + vertical: MediaQuery.of(context).size.height < 600 ? 16 : 40, + ), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 460, + maxHeight: MediaQuery.of(context).size.height * 0.88, + ), + child: FolderFilterDialog( + initialQuery: query, + ), + ), + ), + ); + } + + @override + ConsumerState createState() => + _FolderFilterDialogState(); +} + +class _FolderFilterDialogState extends ConsumerState { + late String _selectedGradeLevel; + late String _selectedSection; + + @override + void initState() { + super.initState(); + _selectedGradeLevel = widget.initialQuery.gradeLevel.isEmpty + ? 'All Grades' + : widget.initialQuery.gradeLevel; + _selectedSection = widget.initialQuery.section.isEmpty + ? 'All Sections' + : widget.initialQuery.section; + } + + void _applyFilters([String? activeYearRange]) { + final notifier = ref.read(documentQueryProvider.notifier); + notifier.setGradeLevel(_selectedGradeLevel == 'All Grades' ? '' : _selectedGradeLevel); + // School Year is selected in background as the active year + if (activeYearRange != null && activeYearRange.isNotEmpty) { + notifier.setSchoolYear(activeYearRange); + } + notifier.setSection(_selectedSection == 'All Sections' ? '' : _selectedSection); + Navigator.pop(context); + } + + void _resetFilters([String? activeYearRange]) { + setState(() { + _selectedGradeLevel = 'All Grades'; + _selectedSection = 'All Sections'; + }); + final notifier = ref.read(documentQueryProvider.notifier); + notifier.setGradeLevel(''); + if (activeYearRange != null && activeYearRange.isNotEmpty) { + notifier.setSchoolYear(activeYearRange); + } + notifier.setSection(''); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + final gradeLevelsAsync = ref.watch(gradeLevelsListProvider); + final sectionsAsync = ref.watch(sectionsListProvider); + final academicYearsAsync = ref.watch(academicYearsListProvider); + + final activeYear = academicYearsAsync.valueOrNull?.firstWhere( + (y) => y.status.toLowerCase() == 'active', + orElse: () => academicYearsAsync.valueOrNull?.isNotEmpty == true + ? academicYearsAsync.valueOrNull!.first + : AcademicYearModel(id: 0, yearRange: '', status: ''), + ); + final activeYearRange = activeYear?.yearRange ?? ''; + + return Container( + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceCard : Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 16, 16), + child: Row( + children: [ + Icon( + Icons.filter_list_rounded, + color: isDark ? AppColors.darkTextPrimary : Colors.black87, + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Filter Folders', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: isDark ? AppColors.darkTextPrimary : Colors.black87, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close_rounded, size: 22), + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + onPressed: () => Navigator.pop(context), + tooltip: 'Close', + ), + ], + ), + ), + const Divider(height: 1, thickness: 1), + + // Body + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + + _buildDropdown( + label: 'Grade Level', + value: _selectedGradeLevel, + items: gradeLevelsAsync.maybeWhen( + data: (grades) => [ + 'All Grades', + ...grades.map((g) => g.name) + ], + orElse: () => ['All Grades'], + ), + onChanged: (val) { + if (val != null) { + setState(() { + _selectedGradeLevel = val; + _selectedSection = 'All Sections'; + }); + } + }, + isDark: isDark, + ), + const SizedBox(height: 20), + _buildDropdown( + label: 'Section', + value: _selectedSection, + items: sectionsAsync.maybeWhen( + data: (sections) { + final sectionNames = {'All Sections'}; + var filteredSections = sections; + + // 1. Only show sections belonging to the active academic year + if (activeYear != null && activeYear.id > 0) { + filteredSections = filteredSections.where((s) { + if (s.academicYearId != null) { + return s.academicYearId == activeYear.id; + } + if (s.academicYearRange != null && s.academicYearRange!.isNotEmpty) { + return s.academicYearRange == activeYear.yearRange; + } + return true; + }).toList(); + } + + // 2. Only show sections belonging to the selected grade level + if (_selectedGradeLevel != 'All Grades') { + final grades = gradeLevelsAsync.valueOrNull ?? []; + final match = grades.where((g) => g.name == _selectedGradeLevel); + if (match.isNotEmpty) { + final levelInt = match.first.level; + filteredSections = filteredSections.where((s) => s.gradeLevel == levelInt).toList(); + } + } + + sectionNames.addAll(filteredSections.map((s) => s.name)); + + // Ensure selected section is in the new list, otherwise reset + if (!sectionNames.contains(_selectedSection)) { + // Delay reset to avoid building error + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _selectedSection = 'All Sections'); + }); + } + + return sectionNames.toList(); + }, + orElse: () => ['All Sections'], + ), + onChanged: (val) { + if (val != null) setState(() => _selectedSection = val); + }, + isDark: isDark, + ), + ], + ), + ), + ), + + // Footer + Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurface2 : Colors.grey.shade50, + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)), + border: Border( + top: BorderSide( + color: isDark ? AppColors.darkBorder : AppColors.borderLight, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => _resetFilters(activeYearRange), + style: TextButton.styleFrom( + foregroundColor: AppColors.error, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + child: const Text('Reset'), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () => _applyFilters(activeYearRange), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + elevation: 0, + ), + child: const Text('Apply Filters'), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDropdown({ + required String label, + required T value, + required List items, + required ValueChanged onChanged, + required bool isDark, + }) { + // Ensure value is in items, otherwise fallback to first item + T currentValue = items.contains(value) ? value : items.first; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + initialValue: currentValue, + isExpanded: true, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + filled: true, + fillColor: isDark ? AppColors.darkSurface2 : Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: isDark ? AppColors.darkBorder : Colors.grey.shade300, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: isDark ? AppColors.darkBorder : Colors.grey.shade300, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide( + color: AppColors.primaryGreen, + width: 2, + ), + ), + ), + dropdownColor: isDark ? AppColors.darkSurfaceCard : Colors.white, + icon: Icon( + Icons.keyboard_arrow_down_rounded, + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + ), + items: items.map((T item) { + return DropdownMenuItem( + value: item, + child: Text( + item.toString(), + style: TextStyle( + color: isDark ? AppColors.darkTextPrimary : Colors.black87, + ), + ), + ); + }).toList(), + onChanged: onChanged, + ), + ], + ); + } +} diff --git a/frontend/lib/ui/screens/students/students_screen.dart b/frontend/lib/ui/screens/students/students_screen.dart index c6caf52..1d534ac 100644 --- a/frontend/lib/ui/screens/students/students_screen.dart +++ b/frontend/lib/ui/screens/students/students_screen.dart @@ -18,6 +18,7 @@ import 'widgets/edit_student_modal.dart'; import 'widgets/bulk_ocr_import_dialog.dart'; import 'widgets/student_filter_dialog.dart'; import 'widgets/student_bulk_actions.dart'; +import 'widgets/bulk_enrollment_modal.dart'; import '../../providers/setup_provider.dart'; import '../../shared/inputs/app_search_bar.dart'; import '../../providers/navigation_provider.dart'; @@ -281,6 +282,7 @@ class _StudentsScreenState extends ConsumerState { student: student, isDialog: true, initialTabIndex: initialTabIndex, + userRole: widget.userRole, ), ), ), @@ -294,6 +296,7 @@ class _StudentsScreenState extends ConsumerState { : EditStudentModal( student: student, initialTabIndex: initialTabIndex, + userRole: widget.userRole, ), ), ); @@ -1458,6 +1461,39 @@ class _StudentsScreenState extends ConsumerState { ), ], + if (widget.userRole == 'admin' || widget.userRole == 'super_admin') ...[ + const SizedBox(width: 6), + Tooltip( + message: 'Bulk Enroll Students (CSV)', + child: SizedBox( + height: 36, + child: OutlinedButton.icon( + onPressed: () => BulkEnrollmentModal.show(context), + icon: const Icon(Icons.school_outlined, size: 16), + label: Text( + isDesktop ? 'Bulk Enroll' : 'Enroll', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + style: OutlinedButton.styleFrom( + foregroundColor: isDark ? Colors.white : AppColors.darkGreen, + side: BorderSide( + color: isDark ? AppColors.darkBorder : AppColors.borderLight, + ), + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 12 : 8, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + ), + ), + ), + ), + ], + // Bulk Import button (hidden on Windows) if (!isWindows && widget.userRole != 'teacher') ...[ const SizedBox(width: 6), @@ -1538,23 +1574,25 @@ class _StudentsScreenState extends ConsumerState { icon: Icons.school_outlined, activeIcon: Icons.school_rounded, label: isMobile ? 'Enrolled' : 'Enrolled$yearLabel', - isSelected: isEnrolled, + isSelected: widget.userRole == 'teacher' ? true : isEnrolled, isDark: isDark, isMobile: isMobile, isMobileOrAndroid: isMobileOrAndroid, - onTap: () => _applyViewTab(StudentViewTab.enrolled), - ), - const SizedBox(width: 2), - _buildSegmentedTabItem( - icon: Icons.people_outline_rounded, - activeIcon: Icons.people_alt_rounded, - label: isMobile ? 'All' : 'All Students', - isSelected: !isEnrolled, - isDark: isDark, - isMobile: isMobile, - isMobileOrAndroid: isMobileOrAndroid, - onTap: () => _applyViewTab(StudentViewTab.all), + onTap: widget.userRole == 'teacher' ? () {} : () => _applyViewTab(StudentViewTab.enrolled), ), + if (widget.userRole != 'teacher') ...[ + const SizedBox(width: 2), + _buildSegmentedTabItem( + icon: Icons.people_outline_rounded, + activeIcon: Icons.people_alt_rounded, + label: isMobile ? 'All' : 'All Students', + isSelected: !isEnrolled, + isDark: isDark, + isMobile: isMobile, + isMobileOrAndroid: isMobileOrAndroid, + onTap: () => _applyViewTab(StudentViewTab.all), + ), + ], ], ), ); diff --git a/frontend/lib/ui/screens/students/widgets/bulk_enrollment_modal.dart b/frontend/lib/ui/screens/students/widgets/bulk_enrollment_modal.dart new file mode 100644 index 0000000..c0d099f --- /dev/null +++ b/frontend/lib/ui/screens/students/widgets/bulk_enrollment_modal.dart @@ -0,0 +1,309 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:file_picker/file_picker.dart'; +import '../../../../core/constants/app_colors.dart'; +import '../../../providers/setup_provider.dart'; +import '../../../providers/student_provider.dart'; +import '../../../shared/dialogs/error_dialog.dart'; +import '../../../shared/dialogs/success_dialog.dart'; + +class BulkEnrollmentModal extends ConsumerStatefulWidget { + const BulkEnrollmentModal({super.key}); + + static Future show(BuildContext context) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const BulkEnrollmentModal(), + ); + } + + @override + ConsumerState createState() => _BulkEnrollmentModalState(); +} + +class _BulkEnrollmentModalState extends ConsumerState { + int? _selectedAcademicYearId; + int? _selectedGradeLevel; + int? _selectedSectionId; + String? _trackStrand; + + bool _isVerifying = false; + bool _isSubmitting = false; + + List> _verifiedStudents = []; + bool _hasAttemptedVerification = false; + + Future _pickAndVerifyCsv() async { + try { + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['csv'], + withData: true, + ); + + if (result == null || result.files.isEmpty) return; + final bytes = result.files.first.bytes; + if (bytes == null) return; + + final csvString = utf8.decode(bytes); + final lines = csvString.split('\n').map((l) => l.trim()).where((l) => l.isNotEmpty).toList(); + + if (lines.isEmpty) { + if (mounted) showErrorDialog(context, 'Error', 'CSV file is empty.'); + return; + } + + // Assume LRN is in the first column or there's a header. + // Let's just aggressively find 12-digit numbers if no header, or extract column. + List lrns = []; + final firstLine = lines.first.toLowerCase(); + + int lrnIndex = -1; + final headers = firstLine.split(','); + for (int i = 0; i < headers.length; i++) { + if (headers[i].contains('lrn')) { + lrnIndex = i; + break; + } + } + + final dataLines = lrnIndex != -1 ? lines.sublist(1) : lines; + for (final line in dataLines) { + final cols = line.split(','); + if (lrnIndex != -1 && cols.length > lrnIndex) { + lrns.add(cols[lrnIndex].trim()); + } else { + // just grab the first column + if (cols.isNotEmpty) lrns.add(cols[0].trim()); + } + } + + lrns = lrns.where((l) => RegExp(r'^\d{12}$').hasMatch(l)).toList(); + if (lrns.isEmpty) { + if (mounted) showErrorDialog(context, 'Error', 'No valid 12-digit LRNs found in the CSV.'); + return; + } + + setState(() => _isVerifying = true); + + final repo = ref.read(studentRepositoryProvider); + final verified = await repo.verifyLrns(lrns); + + setState(() { + _verifiedStudents = verified; + _hasAttemptedVerification = true; + _isVerifying = false; + }); + + } catch (e) { + setState(() => _isVerifying = false); + if (mounted) showErrorDialog(context, 'Error', e.toString()); + } + } + + Future _submitEnrollment() async { + if (_selectedAcademicYearId == null || _selectedGradeLevel == null || _selectedSectionId == null) { + showErrorDialog(context, 'Error', 'Please select Academic Year, Grade Level, and Section.'); + return; + } + + final validIds = _verifiedStudents + .where((s) => s['found'] == true && s['studentId'] != null) + .map((s) => s['studentId'] as int) + .toList(); + + if (validIds.isEmpty) { + showErrorDialog(context, 'Error', 'No valid students to enroll.'); + return; + } + + setState(() => _isSubmitting = true); + try { + final repo = ref.read(studentRepositoryProvider); + await repo.bulkEnrollStudents( + studentIds: validIds, + academicYearId: _selectedAcademicYearId!, + gradeLevel: _selectedGradeLevel!, + sectionId: _selectedSectionId!, + trackStrand: _trackStrand, + ); + + if (mounted) { + showSuccessDialog(context, message: 'Successfully enrolled ${validIds.length} students.'); + Navigator.pop(context); + ref.invalidate(studentPageProvider); + } + } catch (e) { + if (mounted) showErrorDialog(context, 'Error', e.toString()); + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final yearsAsync = ref.watch(academicYearsListProvider); + final gradesAsync = ref.watch(gradeLevelsListProvider); + final sectionsAsync = ref.watch(sectionsListProvider); + + final validCount = _verifiedStudents.where((s) => s['found'] == true).length; + final invalidCount = _verifiedStudents.length - validCount; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + backgroundColor: isDark ? AppColors.darkSurfaceCard : Colors.white, + child: Container( + width: 600, + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Bulk CSV Enrollment', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 16), + const Text( + 'Upload a CSV file containing student LRNs. The system will verify them and enroll valid students into the selected section.', + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Academic Year', border: OutlineInputBorder()), + initialValue: _selectedAcademicYearId, + items: yearsAsync.maybeWhen( + data: (years) => years.map((y) => DropdownMenuItem(value: y.id, child: Text(y.yearRange))).toList(), + orElse: () => [], + ), + onChanged: (val) => setState(() => _selectedAcademicYearId = val), + ), + ), + const SizedBox(width: 16), + Expanded( + child: DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Grade Level', border: OutlineInputBorder()), + initialValue: _selectedGradeLevel, + items: gradesAsync.maybeWhen( + data: (grades) => grades.map((g) => DropdownMenuItem(value: g.level, child: Text(g.name))).toList(), + orElse: () => [], + ), + onChanged: (val) => setState(() => _selectedGradeLevel = val), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Section', border: OutlineInputBorder()), + initialValue: _selectedSectionId, + items: sectionsAsync.maybeWhen( + data: (sections) { + if (_selectedGradeLevel != null) { + sections = sections.where((s) => s.gradeLevel == _selectedGradeLevel).toList(); + } + return sections.map((s) => DropdownMenuItem(value: s.id, child: Text(s.name))).toList(); + }, + orElse: () => [], + ), + onChanged: (val) => setState(() => _selectedSectionId = val), + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + decoration: const InputDecoration(labelText: 'Track/Strand (Optional)', border: OutlineInputBorder()), + onChanged: (val) => _trackStrand = val, + ), + ), + ], + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _isVerifying ? null : _pickAndVerifyCsv, + icon: _isVerifying ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.upload_file), + label: const Text('Select CSV & Verify'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: isDark ? Colors.grey.shade800 : Colors.grey.shade200, + foregroundColor: isDark ? Colors.white : Colors.black87, + ), + ), + if (_hasAttemptedVerification) ...[ + const SizedBox(height: 24), + Text( + 'Verification Results: $validCount valid, $invalidCount invalid', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Container( + height: 200, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: ListView.builder( + itemCount: _verifiedStudents.length, + itemBuilder: (context, index) { + final student = _verifiedStudents[index]; + final found = student['found'] == true; + return ListTile( + dense: true, + leading: Icon( + found ? Icons.check_circle : Icons.error, + color: found ? AppColors.primaryGreen : AppColors.error, + ), + title: Text(student['lrn'] ?? ''), + subtitle: Text(found ? '${student['lastName']}, ${student['firstName']} (${student['status']})' : 'Not found in database'), + ); + }, + ), + ), + ], + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: (_isSubmitting || validCount == 0 || _selectedAcademicYearId == null || _selectedGradeLevel == null || _selectedSectionId == null) + ? null + : _submitEnrollment, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, + ), + child: _isSubmitting + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : Text('Enroll $validCount Students'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/ui/screens/students/widgets/edit_student_modal.dart b/frontend/lib/ui/screens/students/widgets/edit_student_modal.dart index 57ddc32..1007acd 100644 --- a/frontend/lib/ui/screens/students/widgets/edit_student_modal.dart +++ b/frontend/lib/ui/screens/students/widgets/edit_student_modal.dart @@ -20,12 +20,14 @@ class EditStudentModal extends ConsumerStatefulWidget { final StudentModel student; final bool isDialog; final int initialTabIndex; + final String userRole; const EditStudentModal({ super.key, required this.student, this.isDialog = false, this.initialTabIndex = 0, + this.userRole = 'teacher', }); @override @@ -401,7 +403,7 @@ class _EditStudentModalState extends ConsumerState { items: _availableStatuses .map((s) => DropdownMenuItem(value: s, child: Text(s))) .toList(), - onChanged: (v) { + onChanged: widget.userRole == 'teacher' ? null : (v) { if (v == null) return; if (v == 'Graduated') { if (!_hasGraduationEligibleGrade()) { @@ -435,6 +437,7 @@ class _EditStudentModalState extends ConsumerState { controller: _lrnController, keyboardType: TextInputType.number, maxLength: 12, + readOnly: widget.userRole == 'teacher', inputFormatters: [FilteringTextInputFormatter.digitsOnly], validator: _validateLRN, decoration: const InputDecoration( @@ -1015,7 +1018,8 @@ class _EditStudentModalState extends ConsumerState { ], ), ), - IconButton( + if (widget.userRole != 'teacher') + IconButton( icon: const Icon(Icons.edit, size: 18, color: Colors.blue), tooltip: 'Edit Enrollment', @@ -1037,7 +1041,8 @@ class _EditStudentModalState extends ConsumerState { }); }, ), - IconButton( + if (widget.userRole != 'teacher') + IconButton( icon: const Icon( Icons.delete_outline, size: 18, @@ -1124,20 +1129,22 @@ class _EditStudentModalState extends ConsumerState { tooltip: 'Close', ), ], - bottom: TabBar( - labelColor: Colors.white, - unselectedLabelColor: Colors.white70, - indicatorColor: Colors.white, - indicatorWeight: 3, - tabs: [ - Tab( - icon: (!kIsWeb && Platform.isAndroid) ? null : const Icon(Icons.person_outline, size: 20), - text: 'Student Details'), - Tab( - icon: (!kIsWeb && Platform.isAndroid) ? null : const Icon(Icons.school_outlined, size: 20), - text: 'Enrollments'), - ], - ), + bottom: widget.userRole == 'teacher' + ? null + : TabBar( + labelColor: Colors.white, + unselectedLabelColor: Colors.white70, + indicatorColor: Colors.white, + indicatorWeight: 3, + tabs: [ + Tab( + icon: (!kIsWeb && Platform.isAndroid) ? null : const Icon(Icons.person_outline, size: 20), + text: 'Student Details'), + Tab( + icon: (!kIsWeb && Platform.isAndroid) ? null : const Icon(Icons.school_outlined, size: 20), + text: 'Enrollments'), + ], + ), ), body: GestureDetector( onTap: () { @@ -1154,12 +1161,14 @@ class _EditStudentModalState extends ConsumerState { minHeight: 3, ), Expanded( - child: TabBarView( - children: [ - _buildStudentDetailsTab(), - _buildEnrollmentsTab(), - ], - ), + child: widget.userRole == 'teacher' + ? _buildStudentDetailsTab() + : TabBarView( + children: [ + _buildStudentDetailsTab(), + _buildEnrollmentsTab(), + ], + ), ), ], ), diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 1ff86e1..de8a0e2 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -309,10 +309,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 + sha256: "29cc1fdb20613876cc7afc529738c1c0f11a9ca159b010edad0c566ac330847e" url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "11.0.3" firebase_core: dependency: "direct main" description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index a276117..981b329 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -36,7 +36,7 @@ dependencies: flutter_secure_storage: ^10.1.0 google_fonts: ^8.1.0 path_provider: ^2.1.5 - file_picker: ^11.0.2 + file_picker: ^11.0.3 permission_handler: ^12.0.1 socket_io_client: ^3.1.4 shared_preferences: ^2.5.5