feat: enhance teacher permissions, folder filtering, and bulk enrollment verification
This commit is contained in:
1 parent
3e71448add
commit
0058decaef
15 files changed
+1050
-83
No files matched your search
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user