diff --git a/backend/index.js b/backend/index.js index d011cd2..90f4f3e 100644 --- a/backend/index.js +++ b/backend/index.js @@ -65,4 +65,12 @@ app.get(['/', '/api'], (req, res) => { app.listen(PORT, '0.0.0.0', () => { console.log(`Server running on port ${PORT} (0.0.0.0)`); + // Run auto-graduation check on startup and daily + try { + const { checkAndRunAutoGraduation } = require('./src/services/autoGraduationService'); + checkAndRunAutoGraduation(1); + setInterval(() => checkAndRunAutoGraduation(1), 24 * 60 * 60 * 1000); + } catch (err) { + console.error('Failed to start auto-graduation schedule:', err.message); + } }); diff --git a/backend/src/controllers/setupController.js b/backend/src/controllers/setupController.js index 0a0a7f4..f783d74 100644 --- a/backend/src/controllers/setupController.js +++ b/backend/src/controllers/setupController.js @@ -1,4 +1,5 @@ const db = require('../config/db'); +const autoGraduationService = require('../services/autoGraduationService'); // ============================================================ // ACADEMIC YEAR AUTOMATION @@ -58,18 +59,21 @@ exports.getAllAcademicYears = (req, res) => { }; exports.createAcademicYear = (req, res) => { - const { yearRange, status } = req.body; + const { yearRange, status, startDate, endDate, start_date, end_date } = req.body; + const sDate = startDate || start_date || null; + const eDate = endDate || end_date || null; if (!yearRange || !yearRange.trim()) { return res.status(400).json({ message: 'yearRange is required' }); } try { // Old academic years for manual adds default to inactive const finalStatus = status || 'inactive'; - const result = db.prepare('INSERT INTO academic_years (year_range, status) VALUES (?, ?)') - .run(yearRange.trim(), finalStatus); - // If the new year is active, deactivate all others + const result = db.prepare('INSERT INTO academic_years (year_range, status, start_date, end_date) VALUES (?, ?, ?, ?)') + .run(yearRange.trim(), finalStatus, sDate, eDate); + // If the new year is active, deactivate all others and check graduation if (finalStatus === 'active') { deactivateOtherYears(result.lastInsertRowid); + autoGraduationService.checkAndRunAutoGraduation(req.user?.id); } res.status(201).json({ id: result.lastInsertRowid, message: 'Academic year created successfully' }); } catch (error) { @@ -82,7 +86,9 @@ exports.createAcademicYear = (req, res) => { exports.updateAcademicYear = (req, res) => { const { id } = req.params; - const { yearRange, status } = req.body; + const { yearRange, status, startDate, endDate, start_date, end_date } = req.body; + const sDate = startDate !== undefined ? (startDate || null) : (start_date !== undefined ? (start_date || null) : undefined); + const eDate = endDate !== undefined ? (endDate || null) : (end_date !== undefined ? (end_date || null) : undefined); if (!yearRange || !yearRange.trim()) { return res.status(400).json({ message: 'yearRange is required' }); @@ -92,15 +98,18 @@ exports.updateAcademicYear = (req, res) => { } try { - const year = db.prepare('SELECT id FROM academic_years WHERE id = ?').get(id); + const year = db.prepare('SELECT * FROM academic_years WHERE id = ?').get(id); if (!year) return res.status(404).json({ message: 'Academic year not found' }); const finalStatus = status || 'inactive'; - db.prepare('UPDATE academic_years SET year_range = ?, status = ? WHERE id = ?') - .run(yearRange.trim(), finalStatus, id); + const finalStartDate = sDate !== undefined ? sDate : (year.start_date || null); + const finalEndDate = eDate !== undefined ? eDate : (year.end_date || null); + db.prepare('UPDATE academic_years SET year_range = ?, status = ?, start_date = ?, end_date = ? WHERE id = ?') + .run(yearRange.trim(), finalStatus, finalStartDate, finalEndDate, id); // If activated, deactivate all other years automatically if (finalStatus === 'active') { deactivateOtherYears(parseInt(id)); + autoGraduationService.checkAndRunAutoGraduation(req.user?.id); } res.json({ message: 'Academic year updated successfully' }); } catch (error) { @@ -111,6 +120,15 @@ exports.updateAcademicYear = (req, res) => { } }; +exports.checkAutoGraduation = (req, res) => { + try { + const result = autoGraduationService.checkAndRunAutoGraduation(req.user?.id); + res.json(result); + } catch (error) { + res.status(500).json({ message: 'Failed to check auto graduation', error: error.message }); + } +}; + exports.deleteAcademicYear = (req, res) => { const { id } = req.params; try { diff --git a/backend/src/models/schema.js b/backend/src/models/schema.js index 41d1ebb..8178fc3 100644 --- a/backend/src/models/schema.js +++ b/backend/src/models/schema.js @@ -317,10 +317,22 @@ const initSchema = () => { CREATE TABLE IF NOT EXISTS academic_years ( id INTEGER PRIMARY KEY AUTOINCREMENT, year_range TEXT UNIQUE NOT NULL, -- e.g., "2023-2024" - status TEXT CHECK(status IN ('active', 'inactive')) DEFAULT 'active' + status TEXT CHECK(status IN ('active', 'inactive')) DEFAULT 'active', + start_date TEXT DEFAULT NULL, + end_date TEXT DEFAULT NULL ) `).run(); + const ayCols = db.prepare("PRAGMA table_info(academic_years)").all(); + if (!ayCols.some(c => c.name === 'start_date')) { + db.prepare("ALTER TABLE academic_years ADD COLUMN start_date TEXT DEFAULT NULL").run(); + console.log('Migration: added start_date column to academic_years table'); + } + if (!ayCols.some(c => c.name === 'end_date')) { + db.prepare("ALTER TABLE academic_years ADD COLUMN end_date TEXT DEFAULT NULL").run(); + console.log('Migration: added end_date column to academic_years table'); + } + // 2b. Sections Table db.prepare(` CREATE TABLE IF NOT EXISTS sections ( diff --git a/backend/src/routes/setup.js b/backend/src/routes/setup.js index 258d080..2a405a1 100644 --- a/backend/src/routes/setup.js +++ b/backend/src/routes/setup.js @@ -8,6 +8,7 @@ const { authenticateToken, authorizeRoles } = require('../middleware/auth'); // ========================================== router.get('/academic-years', authenticateToken, setupController.getAllAcademicYears); router.post('/academic-years', authenticateToken, authorizeRoles('admin'), setupController.createAcademicYear); +router.post('/academic-years/check-auto-graduation', authenticateToken, authorizeRoles('admin'), setupController.checkAutoGraduation); router.put('/academic-years/:id', authenticateToken, authorizeRoles('admin'), setupController.updateAcademicYear); router.delete('/academic-years/:id', authenticateToken, authorizeRoles('admin'), setupController.deleteAcademicYear); diff --git a/backend/src/services/autoGraduationService.js b/backend/src/services/autoGraduationService.js new file mode 100644 index 0000000..f55ca0b --- /dev/null +++ b/backend/src/services/autoGraduationService.js @@ -0,0 +1,115 @@ +const db = require('../config/db'); + +const logActivity = (userId, action, entityType, entityId, description) => { + try { + let uid = userId; + const userCheck = uid ? db.prepare("SELECT id FROM users WHERE id = ?").get(uid) : null; + if (!userCheck) { + const anyUser = db.prepare("SELECT id FROM users LIMIT 1").get(); + uid = anyUser ? anyUser.id : null; + } + db.prepare(` + INSERT INTO activity_log (user_id, action, entity_type, entity_id, description) + VALUES (?, ?, ?, ?, ?) + `).run(uid, action, entityType, entityId, description); + } catch (err) { + console.error('logActivity error:', err.message); + } +}; + +/** + * Checks the active academic year start_date and end_date. + * If not set, returns { executed: false, reason: 'if not set the start day, month, end year, do not auto graduate' }. + * If set and today >= end_date, sequentially graduates Grade 10 and Grade 12 students in the active academic year. + * Generates ONE compiled log entry for Grade 10, and ONE compiled log entry for Grade 12. + */ +const checkAndRunAutoGraduation = (userId = 1) => { + try { + const activeYear = db.prepare("SELECT * FROM academic_years WHERE status = 'active' LIMIT 1").get(); + if (!activeYear) { + return { executed: false, reason: 'No active academic year found.' }; + } + + if (!activeYear.start_date || !activeYear.end_date) { + return { + executed: false, + reason: 'if not set the start day, month, end year, do not auto graduate (start_date or end_date is missing).' + }; + } + + // Compare today's date (YYYY-MM-DD) with end_date + const todayStr = new Date().toISOString().slice(0, 10); + if (todayStr < activeYear.end_date) { + return { + executed: false, + reason: `Active academic year (${activeYear.year_range}) end date (${activeYear.end_date}) has not been reached yet (today: ${todayStr}).` + }; + } + + // --- Sequentially process Grade 10 students --- + const g10Students = db.prepare(` + SELECT DISTINCT s.id, s.lrn, s.first_name, s.last_name + FROM enrollments e + JOIN students s ON e.student_id = s.id + WHERE e.academic_year_id = ? AND e.grade_level = 10 AND s.status != 'Graduated' + ORDER BY s.id ASC + `).all(activeYear.id); + + const updateStatus = db.prepare("UPDATE students SET status = 'Graduated' WHERE id = ?"); + + if (g10Students.length > 0) { + db.transaction(() => { + for (const st of g10Students) { + updateStatus.run(st.id); + } + })(); + const studentNames = g10Students.map(s => `${s.lrn || ''} (${s.first_name} ${s.last_name})`.trim()).join(', '); + logActivity( + userId, + 'UPDATE', + 'student', + null, + `Auto-graduated ${g10Students.length} Grade 10 student(s) for Academic Year ${activeYear.year_range}: ${studentNames}` + ); + } + + // --- Sequentially process Grade 12 students --- + const g12Students = db.prepare(` + SELECT DISTINCT s.id, s.lrn, s.first_name, s.last_name + FROM enrollments e + JOIN students s ON e.student_id = s.id + WHERE e.academic_year_id = ? AND e.grade_level = 12 AND s.status != 'Graduated' + ORDER BY s.id ASC + `).all(activeYear.id); + + if (g12Students.length > 0) { + db.transaction(() => { + for (const st of g12Students) { + updateStatus.run(st.id); + } + })(); + const studentNames12 = g12Students.map(s => `${s.lrn || ''} (${s.first_name} ${s.last_name})`.trim()).join(', '); + logActivity( + userId, + 'UPDATE', + 'student', + null, + `Auto-graduated ${g12Students.length} Grade 12 student(s) for Academic Year ${activeYear.year_range}: ${studentNames12}` + ); + } + + return { + executed: true, + grade10Graduated: g10Students.length, + grade12Graduated: g12Students.length, + message: `Auto-graduation completed sequentially for active AY ${activeYear.year_range}. Graduated ${g10Students.length} Grade 10 and ${g12Students.length} Grade 12 student(s).` + }; + } catch (err) { + console.error('[autoGraduationService] checkAndRunAutoGraduation error:', err); + return { executed: false, error: err.message }; + } +}; + +module.exports = { + checkAndRunAutoGraduation +}; diff --git a/frontend/lib/domain/entities/setup_models.dart b/frontend/lib/domain/entities/setup_models.dart index 1d69b9f..4ddcc56 100644 --- a/frontend/lib/domain/entities/setup_models.dart +++ b/frontend/lib/domain/entities/setup_models.dart @@ -2,11 +2,15 @@ class AcademicYearModel { final int id; final String yearRange; final String status; + final String? startDate; + final String? endDate; AcademicYearModel({ required this.id, required this.yearRange, required this.status, + this.startDate, + this.endDate, }); factory AcademicYearModel.fromJson(Map json) { @@ -14,6 +18,8 @@ class AcademicYearModel { id: (json['id'] as num).toInt(), yearRange: json['year_range'] as String, status: json['status'] as String, + startDate: json['start_date'] as String? ?? json['startDate'] as String?, + endDate: json['end_date'] as String? ?? json['endDate'] as String?, ); } } diff --git a/frontend/lib/domain/repositories/setup_repository.dart b/frontend/lib/domain/repositories/setup_repository.dart index a04cb8a..027fd81 100644 --- a/frontend/lib/domain/repositories/setup_repository.dart +++ b/frontend/lib/domain/repositories/setup_repository.dart @@ -37,13 +37,20 @@ class SetupRepository { Future createAcademicYear({ required String yearRange, required String status, + String? startDate, + String? endDate, }) async { try { final options = await _getAuthOptions(); await _dio.post( '/setup/academic-years', options: options, - data: {'yearRange': yearRange, 'status': status}, + data: { + 'yearRange': yearRange, + 'status': status, + if (startDate != null) 'startDate': startDate, + if (endDate != null) 'endDate': endDate, + }, ); } on DioException catch (e) { throw Exception( @@ -56,13 +63,20 @@ class SetupRepository { required int id, required String yearRange, required String status, + String? startDate, + String? endDate, }) async { try { final options = await _getAuthOptions(); await _dio.put( '/setup/academic-years/$id', options: options, - data: {'yearRange': yearRange, 'status': status}, + data: { + 'yearRange': yearRange, + 'status': status, + if (startDate != null) 'startDate': startDate, + if (endDate != null) 'endDate': endDate, + }, ); } on DioException catch (e) { throw Exception( @@ -71,6 +85,21 @@ class SetupRepository { } } + Future> checkAutoGraduation() async { + try { + final options = await _getAuthOptions(); + final res = await _dio.post( + '/setup/academic-years/check-auto-graduation', + options: options, + ); + return res.data as Map; + } on DioException catch (e) { + throw Exception( + e.response?.data?['message'] ?? 'Failed to check auto-graduation.', + ); + } + } + Future deleteAcademicYear(int id) async { try { final options = await _getAuthOptions(); diff --git a/frontend/lib/ui/providers/setup_provider.dart b/frontend/lib/ui/providers/setup_provider.dart index 93410a1..12cd07e 100644 --- a/frontend/lib/ui/providers/setup_provider.dart +++ b/frontend/lib/ui/providers/setup_provider.dart @@ -55,11 +55,18 @@ class SetupMutationNotifier extends AsyncNotifier { Future createAcademicYear({ required String yearRange, required String status, + String? startDate, + String? endDate, }) async { state = const AsyncLoading(); try { final repo = ref.read(setupRepositoryProvider); - await repo.createAcademicYear(yearRange: yearRange, status: status); + await repo.createAcademicYear( + yearRange: yearRange, + status: status, + startDate: startDate, + endDate: endDate, + ); state = const AsyncData(null); ref.invalidate(academicYearsListProvider); } catch (e, st) { @@ -72,6 +79,8 @@ class SetupMutationNotifier extends AsyncNotifier { required int id, required String yearRange, required String status, + String? startDate, + String? endDate, }) async { state = const AsyncLoading(); try { @@ -80,6 +89,8 @@ class SetupMutationNotifier extends AsyncNotifier { id: id, yearRange: yearRange, status: status, + startDate: startDate, + endDate: endDate, ); state = const AsyncData(null); ref.invalidate(academicYearsListProvider); @@ -89,6 +100,20 @@ class SetupMutationNotifier extends AsyncNotifier { } } + Future> checkAutoGraduation() async { + state = const AsyncLoading(); + try { + final repo = ref.read(setupRepositoryProvider); + final res = await repo.checkAutoGraduation(); + state = const AsyncData(null); + ref.invalidate(academicYearsListProvider); + return res; + } catch (e, st) { + state = AsyncError(e, st); + rethrow; + } + } + Future deleteAcademicYear(int id) async { state = const AsyncLoading(); try { diff --git a/frontend/lib/ui/screens/settings/settings_screen.dart b/frontend/lib/ui/screens/settings/settings_screen.dart index 8768286..d3ed916 100644 --- a/frontend/lib/ui/screens/settings/settings_screen.dart +++ b/frontend/lib/ui/screens/settings/settings_screen.dart @@ -7,20 +7,15 @@ import '../../shared/inputs/custom_text_field.dart'; import '../../shared/buttons/primary_button.dart'; import '../../shared/dialogs/success_dialog.dart'; import '../../shared/dialogs/error_dialog.dart'; -import '../../shared/modals/custom_modal.dart'; import '../../providers/auth_provider.dart'; import '../../providers/navigation_provider.dart'; import '../../../core/utils/validators.dart'; import 'requirements_settings_screen.dart'; -import 'package:file_picker/file_picker.dart'; -import 'dart:io'; import 'package:dio/dio.dart'; -import '../../providers/backup_provider.dart'; +import '../../providers/setup_provider.dart'; import '../../shared/dialogs/info_dialog.dart'; import 'teacher_management_screen.dart'; -import '../../../core/utils/date_utils.dart' as pht; -import '../../providers/reports_provider.dart'; -import '../../../core/network/api_constants.dart'; +import '../../../domain/entities/setup_models.dart'; import '../../providers/system_settings_provider.dart'; class TitleCaseTextInputFormatter extends TextInputFormatter { @override @@ -87,15 +82,7 @@ class _SettingsScreenState extends ConsumerState { final _newPassCtrl = TextEditingController(); final _confirmPassCtrl = TextEditingController(); - // Single toggle for all password fields - bool _obscurePasswords = true; - bool _isPassVisible = false; - bool _isNewPassVisible = false; - bool _isConfirmPassVisible = false; - - bool _isBackupLoading = false; - bool _isRestoreLoading = false; int? _lastUserId; bool _isProfileLoading = false; @@ -409,526 +396,22 @@ class _SettingsScreenState extends ConsumerState { } } - Future _isServerMachine() async { + Future _handleRunAutoGraduation(BuildContext context, WidgetRef ref) async { try { - final uri = Uri.tryParse(ApiConstants.baseUrl); - if (uri == null) return false; - final host = uri.host; - if (host == 'localhost' || - host == '127.0.0.1' || - host == '::1' || - host == '0.0.0.0') { - return true; - } - final interfaces = await NetworkInterface.list( - includeLoopback: true, - includeLinkLocal: true, + final res = await ref.read(setupMutationProvider.notifier).checkAutoGraduation(); + if (!context.mounted) return; + final executed = res['executed'] == true; + showInfoDialog( + context, + title: executed ? 'Auto-Graduation Completed' : 'Auto-Graduation Skipped', + message: res['message']?.toString() ?? res['reason']?.toString() ?? 'Check completed.', ); - for (final interface in interfaces) { - for (final addr in interface.addresses) { - if (addr.address == host) { - return true; - } - } - } - return false; } catch (e) { - return false; + if (!context.mounted) return; + showErrorDialog(context, 'Check Failed', 'Failed to run auto-graduation check: $e'); } } - Future _getFreeDiskSpaceBytes(String filePath) async { - try { - if (Platform.isWindows) { - final drive = filePath.substring(0, 1).toUpperCase(); - final result = await Process.run( - 'powershell', - [ - '-NoProfile', - '-Command', - '(Get-PSDrive -Name "$drive").Free', - ], - ); - if (result.exitCode == 0) { - return int.tryParse(result.stdout.toString().trim()); - } - } - } catch (_) {} - return null; - } - - void _showManualRestoreGuideDialog(BuildContext context) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Row( - children: [ - Icon(Icons.menu_book_rounded, color: AppColors.primaryGreen), - SizedBox(width: 10), - Expanded(child: Text('Manual Restore Guide')), - ], - ), - content: const SizedBox( - width: 480, - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'If your backup ZIP file is too large to upload over HTTP or you want to restore directly on the server host machine, follow these simple steps:', - style: TextStyle(fontSize: 14, color: AppColors.textPrimary), - ), - SizedBox(height: 16), - Text( - 'Step 1: Stop the TIS RMS Server', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), - ), - Text( - 'Close the terminal/command prompt window or service running the TIS RMS backend server.', - style: TextStyle(fontSize: 13, color: Colors.grey), - ), - SizedBox(height: 12), - Text( - 'Step 2: Locate the Server Data Directory', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), - ), - Text( - 'Open File Explorer and navigate to your server directory (e.g., C:\\SumbrerongBato\\tis_rms_server\\backend\\data).', - style: TextStyle(fontSize: 13, color: Colors.grey), - ), - SizedBox(height: 12), - Text( - 'Step 3: Extract & Overwrite Files', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), - ), - Text( - 'Open your backup .zip file. Copy the database files (tis_rms.db, tis_rms.db-wal, tis_rms.db-shm) and the "students" folder into the backend\\data folder, replacing any existing files.', - style: TextStyle(fontSize: 13, color: Colors.grey), - ), - SizedBox(height: 12), - Text( - 'Step 4: Restart the Server', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), - ), - Text( - 'Start the backend server again. All restored data and uploaded student documents will be immediately available.', - style: TextStyle(fontSize: 13, color: Colors.grey), - ), - ], - ), - ), - ), - actions: [ - FilledButton( - onPressed: () => Navigator.pop(ctx), - style: FilledButton.styleFrom( - backgroundColor: AppColors.primaryGreen, - ), - child: const Text('GOT IT'), - ), - ], - ), - ); - } - - Future _handleBackup() async { - if (!await _isServerMachine()) { - if (!mounted) return; - showErrorDialog( - context, - 'Server Host Only', - 'Backup and Restore operations can only be performed from the server computer itself for data security.\n\nPlease run this action on the computer hosting the TIS RMS Server.', - ); - return; - } - - setState(() => _isBackupLoading = true); - - // Estimate size - String estimatedSizeStr = 'Unknown'; - int estimatedBytes = 0; - try { - estimatedBytes = await ref.read(reportRepositoryProvider).getStorageUsed(); - if (estimatedBytes < 1024) - estimatedSizeStr = '$estimatedBytes B'; - else if (estimatedBytes < 1048576) - estimatedSizeStr = '${(estimatedBytes / 1024).toStringAsFixed(1)} KB'; - else if (estimatedBytes < 1073741824) - estimatedSizeStr = '${(estimatedBytes / 1048576).toStringAsFixed(1)} MB'; - else - estimatedSizeStr = '${(estimatedBytes / 1073741824).toStringAsFixed(1)} GB'; - } catch (_) {} - - setState(() => _isBackupLoading = false); - - if (!mounted) return; - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Confirm Backup'), - content: Text( - 'Are you sure you want to backup the system data and uploaded files?\n\nEstimated ZIP size: $estimatedSizeStr', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('CANCEL'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('BACKUP'), - ), - ], - ), - ); - - if (confirmed != true) return; - - String? saveResult; - final fileName = 'tis_rms_backup_${DateTime.now().toIso8601String().split('T').first}.zip'; - - if (Platform.isAndroid || Platform.isIOS) { - final selectedDirectory = await FilePicker.getDirectoryPath( - dialogTitle: 'Select folder to save backup', - ); - if (selectedDirectory == null) return; - saveResult = '$selectedDirectory/$fileName'; - } else { - saveResult = await FilePicker.saveFile( - dialogTitle: 'Save Backup', - fileName: fileName, - type: FileType.custom, - allowedExtensions: ['zip'], - ); - } - - if (saveResult == null) return; - - // Check disk space before saving - final freeBytes = await _getFreeDiskSpaceBytes(saveResult); - if (freeBytes != null && estimatedBytes > 0 && freeBytes < (estimatedBytes * 1.1)) { - if (!mounted) return; - final freeMb = (freeBytes / (1024 * 1024)).toStringAsFixed(1); - final reqMb = (estimatedBytes / (1024 * 1024)).toStringAsFixed(1); - showErrorDialog( - context, - 'Insufficient Disk Space', - 'You only have $freeMb MB of free space remaining on the destination drive, but the backup is estimated to require ~$reqMb MB.\n\nPlease free up disk space or save to another drive.', - ); - return; - } - - if (!mounted) return; - final result = await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => _TransferProgressDialog( - title: 'Backing Up System Data', - initialStatus: 'Connecting to server...', - action: (onProgress, cancelToken) async { - await ref.read(backupProvider.notifier).downloadBackup( - saveResult!, - onReceiveProgress: onProgress, - cancelToken: cancelToken, - ); - }, - ), - ); - - if (!mounted) return; - if (result == true) { - ref.invalidate(backupInfoProvider); - showInfoDialog( - context, - title: 'Backup Successful', - message: 'System data backed up to $saveResult', - icon: Icons.check_circle_outline, - iconColor: AppColors.primaryGreen, - ); - } else if (result == 'cancelled') { - showInfoDialog( - context, - title: 'Backup Cancelled', - message: 'The backup download was cancelled. No partial files were saved.', - icon: Icons.info_outline, - ); - } else if (result != null) { - showErrorDialog( - context, - 'Backup Failed', - result.toString().replaceAll('Exception: ', ''), - ); - } - } - - Future _handleRestore() async { - if (!await _isServerMachine()) { - if (!mounted) return; - showErrorDialog( - context, - 'Server Host Only', - 'Backup and Restore operations can only be performed from the server computer itself for data security.\n\nPlease run this action on the computer hosting the TIS RMS Server.', - ); - return; - } - - final confirm = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text( - 'Restore System Data', - style: TextStyle(color: AppColors.error), - ), - content: const Text( - 'Restoring a backup will overwrite all current data and uploaded documents.\n\n' - 'The server will shut down after completion. Are you sure you want to proceed?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('CANCEL'), - ), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: AppColors.error), - onPressed: () => Navigator.pop(ctx, true), - child: const Text('RESTORE'), - ), - ], - ), - ); - - if (confirm != true) return; - - final fileResult = await FilePicker.pickFiles( - dialogTitle: 'Select Backup File', - type: FileType.any, - ); - - if (fileResult == null || fileResult.files.single.path == null) return; - - if (!fileResult.files.single.name.toLowerCase().endsWith('.zip')) { - if (!mounted) return; - showErrorDialog( - context, - 'Invalid File', - 'Please select a valid .zip backup file.', - ); - return; - } - - final file = File(fileResult.files.single.path!); - - if (!mounted) return; - final transferResult = await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => _TransferProgressDialog( - title: 'Restoring System Data', - initialStatus: 'Uploading backup archive...', - action: (onProgress, cancelToken) async { - await ref.read(backupProvider.notifier).restoreBackup( - file, - onSendProgress: onProgress, - cancelToken: cancelToken, - ); - }, - ), - ); - - if (!mounted) return; - if (transferResult == true) { - ref.invalidate(backupInfoProvider); - showInfoDialog( - context, - title: 'Restore Successful', - message: - 'Database and files restored successfully.\n\nThe server has been shut down to apply the changes. Please restart the TIS RMS server manually.', - icon: Icons.power_settings_new, - iconColor: AppColors.warning, - ); - } else if (transferResult == 'cancelled') { - showInfoDialog( - context, - title: 'Restore Cancelled', - message: 'The restore upload was cancelled. Your existing system data remains untouched.', - icon: Icons.info_outline, - ); - } else if (transferResult != null) { - final errStr = transferResult.toString().replaceAll('Exception: ', ''); - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Row( - children: [ - Icon(Icons.error_outline, color: AppColors.error), - SizedBox(width: 10), - Text('Restore Failed'), - ], - ), - content: Text( - '$errStr\n\nIf the backup file is too large to upload over HTTP, you can perform a manual restore directly on the server computer.', - ), - actions: [ - OutlinedButton.icon( - onPressed: () { - Navigator.pop(ctx); - _showManualRestoreGuideDialog(context); - }, - icon: const Icon(Icons.help_outline, size: 18), - label: const Text('MANUAL RESTORE GUIDE'), - ), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: AppColors.error), - onPressed: () => Navigator.pop(ctx), - child: const Text('CLOSE'), - ), - ], - ), - ); - } - } - - Widget _buildInfoItem(String label, String value) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: const TextStyle( - fontSize: 12, - color: AppColors.textSecondary, - ), - ), - const SizedBox(height: 4), - Text( - value, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ], - ); - } - - void _showEditProfileModal(BuildContext context) { - CustomModal.show( - context: context, - title: 'Edit Profile Details', - icon: Icons.person_outline, - content: StatefulBuilder( - builder: (context, setModalState) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSizes.p24), - child: Form( - key: _profileFormKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CustomTextField( - hintText: 'First Name', - prefixIcon: Icons.badge_outlined, - controller: _firstNameCtrl, - validator: (v) => - AppValidators.validateRequired(v, 'First Name'), - ), - const SizedBox(height: AppSizes.p16), - CustomTextField( - hintText: 'Middle Name', - prefixIcon: Icons.badge_outlined, - controller: _middleNameCtrl, - ), - const SizedBox(height: AppSizes.p16), - CustomTextField( - hintText: 'Last Name', - prefixIcon: Icons.badge_outlined, - controller: _lastNameCtrl, - validator: (v) => - AppValidators.validateRequired(v, 'Last Name'), - ), - const SizedBox(height: AppSizes.p16), - CustomTextField( - hintText: 'Ext. (Jr)', - prefixIcon: Icons.text_format, - controller: _extCtrl, - ), - const SizedBox(height: AppSizes.p16), - CustomTextField( - hintText: 'Phone Number (Starts with 09)', - prefixIcon: Icons.phone_outlined, - controller: _phoneCtrl, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(11), - ], - keyboardType: TextInputType.phone, - validator: AppValidators.validatePhone, - ), - const SizedBox(height: AppSizes.p16), - CustomTextField( - hintText: 'Email Address', - prefixIcon: Icons.email_outlined, - controller: _emailCtrl, - keyboardType: TextInputType.emailAddress, - validator: AppValidators.validateEmail, - ), - ], - ), - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSizes.p24, - vertical: AppSizes.p16, - ), - decoration: BoxDecoration( - color: Colors.grey.shade50, - border: Border(top: BorderSide(color: Colors.grey.shade200)), - borderRadius: const BorderRadius.vertical( - bottom: Radius.circular(12), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text( - 'CANCEL', - style: TextStyle( - color: Colors.grey, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: AppSizes.p16), - SizedBox( - width: 120, - child: PrimaryButton( - label: 'SAVE', - isLoading: _isProfileLoading, - onPressed: () async { - if (!_profileFormKey.currentState!.validate()) return; - Navigator.pop(context); - await _handleUpdateProfile(); - }, - ), - ), - ], - ), - ), - ], - ); - }, - ), - ); - } - @override Widget build(BuildContext context) { final profileAsync = ref.watch(profileProvider); @@ -1336,146 +819,151 @@ class _SettingsScreenState extends ConsumerState { ), const SizedBox(height: AppSizes.p24), - // ── Database Management (Super Admin Only) ── - _buildCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Row( + // ── Active Academic Year Schedule & Auto-Graduation (Super Admin Only) ── + Consumer( + builder: (context, ref, _) { + final academicYearsAsync = ref.watch(academicYearsListProvider); + return _buildCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.storage, - color: AppColors.primaryGreen, - ), - SizedBox(width: AppSizes.p8), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - 'Database Management', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - Text( - 'Backup or restore system data and uploaded files', - style: TextStyle( - fontSize: 13, - color: AppColors.textSecondary, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - ref - .watch(backupInfoProvider) - .when( - data: (info) => Padding( - padding: const EdgeInsets.only( - bottom: 16, + const Row( + children: [ + Icon( + Icons.school_rounded, + color: AppColors.primaryGreen, ), - child: Row( - children: [ - Expanded( - child: Text( - 'Last Backup: ${(info['lastBackup'] as String?)?.isNotEmpty == true ? pht.formatModalDate(info['lastBackup'] as String) : 'Never'}', - style: const TextStyle( - fontSize: 12, + SizedBox(width: AppSizes.p8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Active Academic Year Schedule & Auto-Graduation', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Set start day/month and end year/month/day for automatic sequential graduation of Grade 10 & 12 students', + style: TextStyle( + fontSize: 13, color: AppColors.textSecondary, ), ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + academicYearsAsync.when( + data: (years) { + final activeYear = years.cast().firstWhere( + (y) => y?.status == 'active', + orElse: () => null, + ); + if (activeYear == null) { + return const Text( + 'No active academic year found. Configure an active academic year in Teacher Management first.', + style: TextStyle(color: AppColors.warning, fontSize: 13), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primaryGreen.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'Active AY: ${activeYear.yearRange}', + style: const TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.primaryGreen, + fontSize: 13, + ), + ), + ), + const SizedBox(width: 16), + Text( + 'Start Date: ${activeYear.startDate ?? "Not set"}', + style: const TextStyle(fontSize: 13), + ), + const SizedBox(width: 16), + Text( + 'End Date: ${activeYear.endDate ?? "Not set"}', + style: const TextStyle(fontSize: 13), + ), + ], + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.shade200), + ), + child: const Row( + children: [ + Icon(Icons.info_outline, size: 16, color: Colors.blue), + SizedBox(width: 8), + Expanded( + child: Text( + 'Note: If Start Date or End Date is not set, auto-graduation will not execute automatically. When the end date arrives, Grade 10 students are graduated first, then Grade 12 students.', + style: TextStyle(fontSize: 12, color: Colors.blue), + ), + ), + ], + ), ), - Expanded( - child: Text( - 'Last Restore: ${(info['lastRestore'] as String?)?.isNotEmpty == true ? pht.formatModalDate(info['lastRestore'] as String) : 'Never'}', - style: const TextStyle( - fontSize: 12, - color: AppColors.textSecondary, + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => showDialog( + context: context, + builder: (_) => AcademicYearFormModal(year: activeYear), + ), + icon: const Icon(Icons.date_range), + label: const Text('Edit Schedule Dates'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + side: const BorderSide(color: AppColors.primaryGreen), + ), + ), ), - ), + const SizedBox(width: 16), + Expanded( + child: FilledButton.icon( + onPressed: () => _handleRunAutoGraduation(context, ref), + icon: const Icon(Icons.school), + label: const Text('Check / Run Auto-Graduation Now'), + style: FilledButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + ], ), ], - ), - ), - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), - ), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: _isBackupLoading - ? null - : _handleBackup, - icon: _isBackupLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.download_rounded), - label: const Text('Backup System Data'), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - vertical: 16, - ), - side: const BorderSide( - color: AppColors.primaryGreen, - ), - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: FilledButton.icon( - onPressed: _isRestoreLoading - ? null - : _handleRestore, - icon: _isRestoreLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.upload_rounded), - label: const Text('Restore System Data'), - style: FilledButton.styleFrom( - backgroundColor: AppColors.warning, - padding: const EdgeInsets.symmetric( - vertical: 16, - ), - ), - ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => Text('Error loading academic years: $err', style: const TextStyle(color: Colors.red)), ), ], ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton.icon( - onPressed: () => _showManualRestoreGuideDialog(context), - icon: const Icon(Icons.help_outline, size: 16), - label: const Text( - 'Backup too large? View Manual Restore Guide', - style: TextStyle(fontSize: 13), - ), - ), - ], - ), - ], - ), + ); + }, ), const SizedBox(height: AppSizes.p24), @@ -1523,7 +1011,7 @@ class _SettingsScreenState extends ConsumerState { ), Switch( value: isAutoEnrollEnabled, - activeColor: AppColors.primaryGreen, + activeThumbColor: AppColors.primaryGreen, onChanged: (val) { ref .read(systemSettingsProvider.notifier) diff --git a/frontend/lib/ui/screens/settings/teacher_management_screen.dart b/frontend/lib/ui/screens/settings/teacher_management_screen.dart index 548434d..59bd8fa 100644 --- a/frontend/lib/ui/screens/settings/teacher_management_screen.dart +++ b/frontend/lib/ui/screens/settings/teacher_management_screen.dart @@ -1217,6 +1217,8 @@ class _AcademicYearFormModalState extends ConsumerState { final _formKey = GlobalKey(); late TextEditingController _yearRangeController; late String _status; + DateTime? _startDate; + DateTime? _endDate; bool _isLoading = false; @override @@ -1227,6 +1229,13 @@ class _AcademicYearFormModalState extends ConsumerState { ); // Default: editing keeps current status; adding defaults to inactive (old years) _status = widget.year?.status ?? 'inactive'; + _startDate = widget.year?.startDate != null ? DateTime.tryParse(widget.year!.startDate!) : null; + _endDate = widget.year?.endDate != null ? DateTime.tryParse(widget.year!.endDate!) : null; + } + + String? _formatYmd(DateTime? dt) { + if (dt == null) return null; + return '${dt.year.toString().padLeft(4, '0')}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; } @override @@ -1293,6 +1302,64 @@ class _AcademicYearFormModalState extends ConsumerState { ], onChanged: (v) => setState(() => _status = v!), ), + const SizedBox(height: AppSizes.p16), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: _startDate ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null) { + setState(() => _startDate = picked); + } + }, + icon: const Icon(Icons.date_range, size: 16), + label: Text(_startDate != null ? 'Start: ${_formatYmd(_startDate)!}' : 'Start Date (Optional)'), + ), + ), + if (_startDate != null) ...[ + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.clear, size: 16), + onPressed: () => setState(() => _startDate = null), + ), + ], + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: _endDate ?? (_startDate ?? DateTime.now()), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null) { + setState(() => _endDate = picked); + } + }, + icon: const Icon(Icons.event, size: 16), + label: Text(_endDate != null ? 'End: ${_formatYmd(_endDate)!}' : 'End Date (Optional)'), + ), + ), + if (_endDate != null) ...[ + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.clear, size: 16), + onPressed: () => setState(() => _endDate = null), + ), + ], + ], + ), ], ), ), @@ -1314,6 +1381,8 @@ class _AcademicYearFormModalState extends ConsumerState { if (!_formKey.currentState!.validate()) return; setState(() => _isLoading = true); try { + final startStr = _formatYmd(_startDate); + final endStr = _formatYmd(_endDate); if (widget.year != null) { await ref .read(setupMutationProvider.notifier) @@ -1321,6 +1390,8 @@ class _AcademicYearFormModalState extends ConsumerState { id: widget.year!.id, yearRange: _yearRangeController.text.trim(), status: _status, + startDate: startStr, + endDate: endStr, ); } else { await ref @@ -1328,6 +1399,8 @@ class _AcademicYearFormModalState extends ConsumerState { .createAcademicYear( yearRange: _yearRangeController.text.trim(), status: _status, + startDate: startStr, + endDate: endStr, ); } if (!mounted) return;