feat: enhance teacher permissions, folder filtering, and bulk enrollment verification

This commit is contained in:
ssbg04 committed 2026-10-04 11:24:07 +08:00
1 parent 3e71448add
commit 0058decaef
15 files changed
+1050 -83

No files matched your search

+14 -3
View File
@@ -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()) {
+56 -7
View File
@@ -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);
+58 -5
View File
@@ -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
// ============================================================
+1
View File
@@ -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);
@@ -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,
);
@@ -448,6 +448,54 @@ class StudentRepository {
}
}
// ----------------------------------------------------------------
// Bulk Enroll Students
// ----------------------------------------------------------------
Future<void> bulkEnrollStudents({
required List<int> 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<List<Map<String, dynamic>>> verifyLrns(List<String> lrns) async {
try {
final options = await _getAuthOptions();
final response = await _dio.post(
'/students/verify-lrns',
data: {'lrns': lrns},
options: options,
);
final List<dynamic> verified = response.data['verified'] ?? [];
return verified.map((v) => v as Map<String, dynamic>).toList();
} on DioException catch (e) {
final msg = e.response?.data?['message'] ?? 'Failed to verify LRNs.';
throw Exception(msg);
}
}
// ----------------------------------------------------------------
// Bulk Create Students from OCR Import
// ----------------------------------------------------------------
@@ -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<DocumentQueryParams> {
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<List<FolderModel>>((
) 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,
);
});
// ============================================================
@@ -2281,12 +2281,15 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
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,
),
),
),
],
@@ -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<DocumentsScreen>
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<String> _jhsItems = [];
@@ -105,8 +108,26 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
}
});
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<AcademicYearModel> years =
ref.read(academicYearsListProvider).asData?.value ?? [];
if (years.isEmpty) {
try {
years = await ref.read(academicYearsListProvider.future);
} catch (_) {}
}
final active = years.cast<AcademicYearModel?>().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<DocumentsScreen>
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<AcademicYearModel?>().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<DocumentsScreen>
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<DocumentsScreen>
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<String>(
@@ -1460,11 +1532,17 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
);
}
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<DocumentsScreen>
),
),
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<DocumentsScreen>
),
),
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<DocumentsScreen>
),
if (_selectedDocumentType != 'All Types' ||
_selectedGradeLevel != 'All Grades' ||
_selectedSchoolYear != 'All Years' ||
_searchController.text.isNotEmpty) ...[
const SizedBox(height: 16),
TextButton.icon(
@@ -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<void> 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<FolderFilterDialog> createState() =>
_FolderFilterDialogState();
}
class _FolderFilterDialogState extends ConsumerState<FolderFilterDialog> {
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<String>(
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<String>(
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<T>({
required String label,
required T value,
required List<T> items,
required ValueChanged<T?> 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<T>(
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<T>(
value: item,
child: Text(
item.toString(),
style: TextStyle(
color: isDark ? AppColors.darkTextPrimary : Colors.black87,
),
),
);
}).toList(),
onChanged: onChanged,
),
],
);
}
}
@@ -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<StudentsScreen> {
student: student,
isDialog: true,
initialTabIndex: initialTabIndex,
userRole: widget.userRole,
),
),
),
@@ -294,6 +296,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
: EditStudentModal(
student: student,
initialTabIndex: initialTabIndex,
userRole: widget.userRole,
),
),
);
@@ -1458,6 +1461,39 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
),
],
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<StudentsScreen> {
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),
),
],
],
),
);
@@ -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<void> show(BuildContext context) {
return showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const BulkEnrollmentModal(),
);
}
@override
ConsumerState<BulkEnrollmentModal> createState() => _BulkEnrollmentModalState();
}
class _BulkEnrollmentModalState extends ConsumerState<BulkEnrollmentModal> {
int? _selectedAcademicYearId;
int? _selectedGradeLevel;
int? _selectedSectionId;
String? _trackStrand;
bool _isVerifying = false;
bool _isSubmitting = false;
List<Map<String, dynamic>> _verifiedStudents = [];
bool _hasAttemptedVerification = false;
Future<void> _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<String> 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<void> _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<int>(
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<int>(
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<int>(
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'),
),
],
),
],
),
),
);
}
}
@@ -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<EditStudentModal> {
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<EditStudentModal> {
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<EditStudentModal> {
],
),
),
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<EditStudentModal> {
});
},
),
IconButton(
if (widget.userRole != 'teacher')
IconButton(
icon: const Icon(
Icons.delete_outline,
size: 18,
@@ -1124,20 +1129,22 @@ class _EditStudentModalState extends ConsumerState<EditStudentModal> {
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<EditStudentModal> {
minHeight: 3,
),
Expanded(
child: TabBarView(
children: [
_buildStudentDetailsTab(),
_buildEnrollmentsTab(),
],
),
child: widget.userRole == 'teacher'
? _buildStudentDetailsTab()
: TabBarView(
children: [
_buildStudentDetailsTab(),
_buildEnrollmentsTab(),
],
),
),
],
),
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -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