From 69b4879ac27b5084e0741e10c8a605ad1d481049 Mon Sep 17 00:00:00 2001 From: ssbg04 Date: Sat, 19 Sep 2026 02:26:49 +0800 Subject: [PATCH] feat(dashboard,ui): overhaul dashboard KPIs, audit trail tab, responsive controls, and settings - Remove recent activities and user history from dashboard; relocate to dedicated History tab - Fix Document Breakdown overflow and rebalance Bento KPI grid layout - Fix Android overflow on student profile Edit Details button - Simplify grid view cards in documents and archives screens with minimal folder styling - Fix transparency board filter contrast and export button styling in reports - Default auto-update enrollment to off, add grace period input, and fix settings danger zone --- backend/index.js | 9 + backend/package.json | 6 + .../src/controllers/dashboardController.js | 27 +- backend/src/controllers/settingsController.js | 8 +- backend/src/controllers/setupController.js | 14 +- backend/src/controllers/userController.js | 33 +- backend/src/models/schema.js | 2 +- backend/src/routes/setup.js | 1 + backend/src/services/autoArchiveService.js | 125 ++ frontend/AGENTS.md | 49 +- .../repositories/activity_repository.dart | 2 + .../repositories/dashboard_repository.dart | 5 +- .../ui/layouts/android_bottom_nav_layout.dart | 15 + .../ui/layouts/windows_sidebar_layout.dart | 16 + .../lib/ui/providers/activity_provider.dart | 6 + .../lib/ui/providers/dashboard_provider.dart | 7 + .../lib/ui/providers/reports_provider.dart | 4 + frontend/lib/ui/providers/theme_provider.dart | 8 +- .../ui/screens/archives/archives_screen.dart | 33 +- .../audit_trail/audit_trail_screen.dart | 1133 +++++++++++++++ .../screens/dashboard/dashboard_screen.dart | 476 +------ .../dashboard/widgets/dashboard_kpis.dart | 411 ++---- .../screens/documents/documents_screen.dart | 61 +- .../widgets/student_profile_modal.dart | 223 ++- .../ui/screens/reports/reports_screen.dart | 1266 +++++++++++++---- .../widgets/transparency_board_section.dart | 47 +- .../ui/screens/settings/settings_screen.dart | 156 +- .../lib/ui/shared/buttons/primary_button.dart | 44 +- 28 files changed, 2981 insertions(+), 1206 deletions(-) create mode 100644 backend/src/services/autoArchiveService.js create mode 100644 frontend/lib/ui/screens/audit_trail/audit_trail_screen.dart diff --git a/backend/index.js b/backend/index.js index 25d0bff..27d933f 100644 --- a/backend/index.js +++ b/backend/index.js @@ -76,6 +76,15 @@ app.listen(PORT, '0.0.0.0', () => { console.error('Failed to start auto-graduation schedule:', err.message); } + // Run auto-archiving check on startup and daily (for Enrolled students without active enrollment past grace period) + try { + const { checkAndRunAutoArchive } = require('./src/services/autoArchiveService'); + checkAndRunAutoArchive(1); + setInterval(() => checkAndRunAutoArchive(1), 24 * 60 * 60 * 1000); + } catch (err) { + console.error('Failed to start auto-archiving schedule:', err.message); + } + // Initialize Auto Cloudflare Tunnel activation when internet is connected try { const { startAutoTunnel } = require('./src/services/tunnelService'); diff --git a/backend/package.json b/backend/package.json index b707a5c..85c00fe 100644 --- a/backend/package.json +++ b/backend/package.json @@ -34,5 +34,11 @@ }, "devDependencies": { "nodemon": "^3.1.14" + }, + "allowScripts": { + "@firebase/util@1.15.2": true, + "bcrypt@6.0.0": true, + "better-sqlite3@12.9.0": true, + "protobufjs@7.6.5": true } } diff --git a/backend/src/controllers/dashboardController.js b/backend/src/controllers/dashboardController.js index 6e687fb..4d60de5 100644 --- a/backend/src/controllers/dashboardController.js +++ b/backend/src/controllers/dashboardController.js @@ -146,7 +146,8 @@ exports.getRecentActivities = (req, res) => { const offset = (page - 1) * limit; const dateFrom = req.query.date_from || ''; const dateTo = req.query.date_to || ''; - const action = req.query.action || ''; + const action = (req.query.action || '').trim(); + const search = (req.query.search || '').trim(); // Comma-separated entity type filter, e.g. "student,document" (teacher view) const entityTypesRaw = req.query.entity_types || ''; @@ -155,7 +156,29 @@ exports.getRecentActivities = (req, res) => { if (dateFrom) { conditions.push("DATE(a.created_at) >= DATE(?)"); params.push(dateFrom); } if (dateTo) { conditions.push("DATE(a.created_at) <= DATE(?)"); params.push(dateTo); } - if (action) { conditions.push("a.action = ?"); params.push(action); } + + if (action) { + const actUpper = action.toUpperCase(); + if (actUpper === 'ALL' || actUpper === 'ALL ACTIONS') { + // no action filter + } else if (actUpper === 'ADD' || actUpper === 'CREATE') { + conditions.push("(UPPER(a.action) IN ('CREATE', 'ADD'))"); + } else if (actUpper === 'UPDATE') { + conditions.push("UPPER(a.action) = 'UPDATE'"); + } else if (actUpper === 'DELETE') { + conditions.push("UPPER(a.action) = 'DELETE'"); + } else if (actUpper === 'ARCHIVE') { + conditions.push("(UPPER(a.action) = 'ARCHIVE' OR a.description LIKE '%archive%' OR a.description LIKE '%Inactive%' OR a.description LIKE '%Graduated%')"); + } else { + conditions.push("UPPER(a.action) = ?"); + params.push(actUpper); + } + } + + if (search) { + conditions.push("(a.description LIKE ? OR COALESCE(u.username, dh.username, 'System') LIKE ? OR COALESCE(u.first_name || ' ' || u.last_name, dh.full_name, 'System') LIKE ?)"); + params.push(`%${search}%`, `%${search}%`, `%${search}%`); + } if (entityTypesRaw) { const types = entityTypesRaw.split(',').map(t => t.trim()).filter(Boolean); diff --git a/backend/src/controllers/settingsController.js b/backend/src/controllers/settingsController.js index c83632b..1165a1c 100644 --- a/backend/src/controllers/settingsController.js +++ b/backend/src/controllers/settingsController.js @@ -1,10 +1,12 @@ const db = require('../config/db'); +const autoArchiveService = require('../services/autoArchiveService'); exports.getSettings = (req, res) => { try { const rows = db.prepare('SELECT key, value FROM system_settings').all(); const settings = { - auto_update_enrollment_from_sf: 'true' + auto_update_enrollment_from_sf: 'false', + enrollment_grace_period_days: '30' }; for (const row of rows) { settings[row.key] = row.value; @@ -49,6 +51,10 @@ exports.updateSettings = (req, res) => { insertOrUpdate(updates); + if (updates.enrollment_grace_period_days !== undefined) { + autoArchiveService.checkAndRunAutoArchive(req.user?.id); + } + const rows = db.prepare('SELECT key, value FROM system_settings').all(); const updatedSettings = {}; for (const row of rows) { diff --git a/backend/src/controllers/setupController.js b/backend/src/controllers/setupController.js index c02416c..2760743 100644 --- a/backend/src/controllers/setupController.js +++ b/backend/src/controllers/setupController.js @@ -1,5 +1,6 @@ const db = require('../config/db'); const autoGraduationService = require('../services/autoGraduationService'); +const autoArchiveService = require('../services/autoArchiveService'); // ============================================================ // ACADEMIC YEAR AUTOMATION @@ -85,10 +86,11 @@ exports.createAcademicYear = (req, res) => { const finalStatus = status || 'inactive'; const result = db.prepare('INSERT INTO academic_years (year_range, status, start_date, end_date) VALUES (?, ?, ?, ?)') .run(normalizedRange, finalStatus, sDate, eDate); - // If the new year is active, deactivate all others and check graduation + // If the new year is active, deactivate all others and check graduation & archiving if (finalStatus === 'active') { deactivateOtherYears(result.lastInsertRowid); autoGraduationService.checkAndRunAutoGraduation(req.user?.id); + autoArchiveService.checkAndRunAutoArchive(req.user?.id); } res.status(201).json({ id: result.lastInsertRowid, message: 'Academic year created successfully' }); } catch (error) { @@ -142,6 +144,7 @@ exports.updateAcademicYear = (req, res) => { if (finalStatus === 'active') { deactivateOtherYears(parseInt(id)); autoGraduationService.checkAndRunAutoGraduation(req.user?.id); + autoArchiveService.checkAndRunAutoArchive(req.user?.id); } res.json({ message: 'Academic year updated successfully' }); } catch (error) { @@ -161,6 +164,15 @@ exports.checkAutoGraduation = (req, res) => { } }; +exports.checkAutoArchive = (req, res) => { + try { + const result = autoArchiveService.checkAndRunAutoArchive(req.user?.id); + res.json(result); + } catch (error) { + res.status(500).json({ message: 'Failed to check auto archive', error: error.message }); + } +}; + exports.deleteAcademicYear = (req, res) => { const { id } = req.params; try { diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js index dcb4053..0b1907e 100644 --- a/backend/src/controllers/userController.js +++ b/backend/src/controllers/userController.js @@ -497,16 +497,43 @@ exports.getUserHistory = (req, res) => { const offset = (page - 1) * limit; const dateFrom = req.query.date_from || ''; const dateTo = req.query.date_to || ''; - const action = req.query.action || ''; + const action = (req.query.action || '').trim(); const role = req.query.role || ''; + const search = (req.query.search || '').trim(); const conditions = []; const params = []; if (dateFrom) { conditions.push("DATE(h.created_at) >= DATE(?)"); params.push(dateFrom); } if (dateTo) { conditions.push("DATE(h.created_at) <= DATE(?)"); params.push(dateTo); } - if (action) { conditions.push("h.action = ?"); params.push(action); } - if (role) { conditions.push("h.role = ?"); params.push(role); } + + if (action) { + const actLower = action.toLowerCase(); + if (actLower === 'all' || actLower === 'all actions') { + // no action filter + } else if (actLower === 'add' || actLower === 'create' || actLower === 'created') { + conditions.push("LOWER(h.action) IN ('created', 'create', 'add')"); + } else if (actLower === 'update' || actLower === 'updated') { + conditions.push("LOWER(h.action) IN ('updated', 'update')"); + } else if (actLower === 'delete' || actLower === 'deleted') { + conditions.push("LOWER(h.action) IN ('deleted', 'delete')"); + } else if (actLower === 'archive' || actLower === 'archived') { + conditions.push("(LOWER(h.action) IN ('archived', 'archive') OR h.action LIKE '%archive%')"); + } else { + conditions.push("LOWER(h.action) = ?"); + params.push(actLower); + } + } + + if (role) { + conditions.push("LOWER(h.role) = LOWER(?)"); + params.push(role); + } + + if (search) { + conditions.push("(h.username LIKE ? OR h.full_name LIKE ? OR COALESCE(u.username, dh.username, 'System') LIKE ? OR COALESCE(u.first_name || ' ' || u.last_name, dh.full_name, 'System') LIKE ?)"); + params.push(`%${search}%`, `%${search}%`, `%${search}%`, `%${search}%`); + } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; diff --git a/backend/src/models/schema.js b/backend/src/models/schema.js index bd62117..375f0d0 100644 --- a/backend/src/models/schema.js +++ b/backend/src/models/schema.js @@ -768,7 +768,7 @@ const initSchema = () => { `).run(); db.prepare(` INSERT OR IGNORE INTO system_settings (key, value) - VALUES ('auto_update_enrollment_from_sf', 'true') + VALUES ('auto_update_enrollment_from_sf', 'false') `).run(); // Seed Admin if not exists diff --git a/backend/src/routes/setup.js b/backend/src/routes/setup.js index 2a405a1..dfaa5a5 100644 --- a/backend/src/routes/setup.js +++ b/backend/src/routes/setup.js @@ -9,6 +9,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.post('/academic-years/check-auto-archive', authenticateToken, authorizeRoles('admin'), setupController.checkAutoArchive); 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/autoArchiveService.js b/backend/src/services/autoArchiveService.js new file mode 100644 index 0000000..425809d --- /dev/null +++ b/backend/src/services/autoArchiveService.js @@ -0,0 +1,125 @@ +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('[autoArchiveService] logActivity error:', err.message); + } +}; + +/** + * Checks the active academic year start_date. + * If not set or no active year, does not run auto-archive. + * Grace period (in days) is fetched from system_settings ('enrollment_grace_period_days', default 30). + * If today >= (start_date + grace_period_days): + * ONLY targets students with status = 'Enrolled' (never touches Graduated, Transferred, Dropped, or Inactive). + * Checks which of these 'Enrolled' students do NOT have an enrollment record in the active academic year. + * Updates their status to 'Inactive'. + * Archives their active documents (status = 'Archived'). + * Logs the activity to activity_log. + */ +const checkAndRunAutoArchive = (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) { + return { + executed: false, + reason: 'Active academic year has no start_date configured.' + }; + } + + // Fetch grace period days from system_settings (defaults to 30 days) + const graceRow = db.prepare("SELECT value FROM system_settings WHERE key = 'enrollment_grace_period_days'").get(); + const graceDays = Math.max(1, parseInt(graceRow?.value || '30', 10)); + + // Calculate cutoff date: start_date + graceDays + const startDate = new Date(activeYear.start_date + 'T00:00:00'); + if (isNaN(startDate.getTime())) { + return { executed: false, reason: 'Invalid start_date format in active academic year.' }; + } + + const cutoffDate = new Date(startDate); + cutoffDate.setDate(cutoffDate.getDate() + graceDays); + const cutoffDateStr = cutoffDate.toISOString().slice(0, 10); + + const todayStr = new Date().toISOString().slice(0, 10); + if (todayStr < cutoffDateStr) { + return { + executed: false, + reason: `Enrollment grace period (${graceDays} days) from academic year start (${activeYear.start_date}) has not been reached yet. Cutoff date is ${cutoffDateStr} (today: ${todayStr}).` + }; + } + + // STRICT REQUIREMENT: ONLY TARGET STUDENTS WITH status = 'Enrolled' + // Never alters students who are already Graduated, Transferred, Dropped, or Inactive. + const unEnrolledStudents = db.prepare(` + SELECT DISTINCT s.id, s.lrn, s.first_name, s.last_name + FROM students s + WHERE s.status = 'Enrolled' + AND s.id NOT IN ( + SELECT student_id FROM enrollments WHERE academic_year_id = ? + ) + ORDER BY s.id ASC + `).all(activeYear.id); + + if (unEnrolledStudents.length === 0) { + return { + executed: true, + archivedCount: 0, + message: `All enrolled students are registered in active academic year ${activeYear.year_range}. No students auto-archived.` + }; + } + + const updateStudentStatus = db.prepare("UPDATE students SET status = 'Inactive' WHERE id = ?"); + const archiveStudentDocs = db.prepare(` + UPDATE documents + SET status = 'Archived' + WHERE student_id = ? AND deleted_at IS NULL AND status != 'Archived' + `); + + db.transaction(() => { + for (const st of unEnrolledStudents) { + updateStudentStatus.run(st.id); + archiveStudentDocs.run(st.id); + } + })(); + + const studentSummary = unEnrolledStudents.slice(0, 20).map(s => `${s.lrn || ''} (${s.first_name} ${s.last_name})`.trim()).join(', '); + const extraText = unEnrolledStudents.length > 20 ? ` and ${unEnrolledStudents.length - 20} more` : ''; + + logActivity( + userId, + 'ARCHIVE', + 'student', + null, + `Auto-archived ${unEnrolledStudents.length} student(s) to 'Inactive' (no enrollment in AY ${activeYear.year_range} after ${graceDays}-day grace period): ${studentSummary}${extraText}` + ); + + return { + executed: true, + archivedCount: unEnrolledStudents.length, + message: `Successfully auto-archived ${unEnrolledStudents.length} student(s) to 'Inactive'.` + }; + } catch (err) { + console.error('[autoArchiveService] checkAndRunAutoArchive error:', err); + return { executed: false, error: err.message }; + } +}; + +module.exports = { + checkAndRunAutoArchive +}; diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 55ffebd..02b3856 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -681,7 +681,7 @@ Files backup/restore - mirror mode or zip --> - `UpdateAvailableDialog` with installed vs latest version pill, platform asset tag, release notes, and download link. - `SplashScreen` check with 4-second timeout and non-blocking background error handling. --> -# STUDENT SCREEN + # REPORT SCREEN - DEPED TRANSPARENCY BOARD, add collapse information for each widgets add short description for what each widget is for -- use tab design simple +- use tab design simple --> + + + + +# DASHBOARD SCREEN +- document breakdown fix the overflowed +- remove from the frontend user history and recent activities + +# STUDENT SCREEN +- fix overflow on edit details button on android app + +# DOCUMENTS AND ARCHIVES SCREEN +- fix that on grid view, only large folder icon and under it the name and the status chip and count of docs ex. (3) + +# REPORTS SCREEN +- academic year filter on deped transparency board not readable +- compliance & analytics export label color is not theme responsive and reduce size + +# SETTINGS SCREEN +- default the auto update enrollment turned off +- add input for automatic archiving grace period +- change password update label color not theme responsive +- the danger zone is transparent fix it --- diff --git a/frontend/lib/domain/repositories/activity_repository.dart b/frontend/lib/domain/repositories/activity_repository.dart index 7febb32..e8fe319 100644 --- a/frontend/lib/domain/repositories/activity_repository.dart +++ b/frontend/lib/domain/repositories/activity_repository.dart @@ -20,6 +20,7 @@ class ActivityRepository { String? dateTo, String? action, String? role, + String? search, }) async { try { final options = await _getAuthOptions(); @@ -32,6 +33,7 @@ class ActivityRepository { if (dateTo != null && dateTo.isNotEmpty) 'date_to': dateTo, if (action != null && action.isNotEmpty) 'action': action, if (role != null && role.isNotEmpty) 'role': role, + if (search != null && search.isNotEmpty) 'search': search, }, options: options, ); diff --git a/frontend/lib/domain/repositories/dashboard_repository.dart b/frontend/lib/domain/repositories/dashboard_repository.dart index 7111a77..7477b88 100644 --- a/frontend/lib/domain/repositories/dashboard_repository.dart +++ b/frontend/lib/domain/repositories/dashboard_repository.dart @@ -28,8 +28,8 @@ class DashboardRepository { String? dateFrom, String? dateTo, String? action, - String? - entityTypes, // Comma-separated, e.g. "student,document" for teacher view + String? entityTypes, // Comma-separated, e.g. "student,document" for teacher view + String? search, }) async { try { final options = await _getAuthOptions(); @@ -43,6 +43,7 @@ class DashboardRepository { if (action != null && action.isNotEmpty) 'action': action, if (entityTypes != null && entityTypes.isNotEmpty) 'entity_types': entityTypes, + if (search != null && search.isNotEmpty) 'search': search, }, options: options, ); diff --git a/frontend/lib/ui/layouts/android_bottom_nav_layout.dart b/frontend/lib/ui/layouts/android_bottom_nav_layout.dart index e563247..3828584 100644 --- a/frontend/lib/ui/layouts/android_bottom_nav_layout.dart +++ b/frontend/lib/ui/layouts/android_bottom_nav_layout.dart @@ -9,6 +9,7 @@ import '../screens/documents/documents_screen.dart'; import '../screens/archives/archives_screen.dart'; import '../screens/reports/reports_screen.dart'; import '../screens/users/users_screen.dart'; +import '../screens/audit_trail/audit_trail_screen.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; // Import Riverpod import '../providers/dashboard_provider.dart'; @@ -17,6 +18,7 @@ import '../providers/document_provider.dart'; import '../providers/archives_provider.dart'; import '../providers/reports_provider.dart'; import '../providers/users_provider.dart'; +import '../providers/activity_provider.dart'; import '../providers/auth_provider.dart'; import '../providers/setup_provider.dart'; import '../providers/system_settings_provider.dart'; @@ -323,6 +325,13 @@ class _AndroidBottomNavLayoutState extends ConsumerState 'screen': const UsersScreen(), 'roles': ['admin'], }, + { + 'label': 'History', + 'icon': Icons.manage_history_outlined, + 'activeIcon': Icons.manage_history_rounded, + 'screen': AuditTrailScreen(userRole: widget.userRole), + 'roles': ['admin'], + }, { 'label': 'Settings', 'icon': Icons.settings_outlined, @@ -385,6 +394,12 @@ class _AndroidBottomNavLayoutState extends ConsumerState ref.read(userSearchQueryProvider.notifier).state = ''; ref.invalidate(usersProvider); break; + case 'History': + ref.invalidate(activityQueryProvider); + ref.invalidate(recentActivitiesPageProvider); + ref.invalidate(userHistoryQueryProvider); + ref.invalidate(userHistoryPageProvider); + break; case 'Settings': ref.invalidate(academicYearsListProvider); ref.invalidate(sectionsListProvider); diff --git a/frontend/lib/ui/layouts/windows_sidebar_layout.dart b/frontend/lib/ui/layouts/windows_sidebar_layout.dart index 8af161a..27f230c 100644 --- a/frontend/lib/ui/layouts/windows_sidebar_layout.dart +++ b/frontend/lib/ui/layouts/windows_sidebar_layout.dart @@ -9,6 +9,7 @@ import '../screens/documents/documents_screen.dart'; import '../screens/archives/archives_screen.dart'; import '../screens/reports/reports_screen.dart'; import '../screens/users/users_screen.dart'; +import '../screens/audit_trail/audit_trail_screen.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; // Import Riverpod import '../providers/dashboard_provider.dart'; import '../providers/student_provider.dart' hide academicYearsProvider; @@ -16,6 +17,7 @@ import '../providers/document_provider.dart'; import '../providers/archives_provider.dart'; import '../providers/reports_provider.dart'; import '../providers/users_provider.dart'; +import '../providers/activity_provider.dart'; import '../providers/auth_provider.dart'; import '../providers/setup_provider.dart'; import '../providers/system_settings_provider.dart'; @@ -106,6 +108,12 @@ class _WindowsSidebarLayoutState extends ConsumerState { ref.read(userSearchQueryProvider.notifier).state = ''; ref.invalidate(usersProvider); break; + case 'History': + ref.invalidate(activityQueryProvider); + ref.invalidate(recentActivitiesPageProvider); + ref.invalidate(userHistoryQueryProvider); + ref.invalidate(userHistoryPageProvider); + break; case 'Settings': ref.invalidate(academicYearsListProvider); ref.invalidate(sectionsListProvider); @@ -318,6 +326,14 @@ class _WindowsSidebarLayoutState extends ConsumerState { 'screen': const UsersScreen(), 'roles': ['admin'], }, + { + 'category': 'ACCOUNT', + 'label': 'History', + 'icon': Icons.manage_history_outlined, + 'activeIcon': Icons.manage_history_rounded, + 'screen': AuditTrailScreen(userRole: widget.userRole), + 'roles': ['admin'], + }, { 'category': 'ACCOUNT', 'label': 'Settings', diff --git a/frontend/lib/ui/providers/activity_provider.dart b/frontend/lib/ui/providers/activity_provider.dart index 05de2d2..0d2c872 100644 --- a/frontend/lib/ui/providers/activity_provider.dart +++ b/frontend/lib/ui/providers/activity_provider.dart @@ -14,6 +14,7 @@ class UserHistoryQueryParams { final String dateTo; final String action; final String role; + final String search; const UserHistoryQueryParams({ this.page = 1, @@ -22,6 +23,7 @@ class UserHistoryQueryParams { this.dateTo = '', this.action = '', this.role = '', + this.search = '', }); UserHistoryQueryParams copyWith({ @@ -31,6 +33,7 @@ class UserHistoryQueryParams { String? dateTo, String? action, String? role, + String? search, }) { return UserHistoryQueryParams( page: page ?? this.page, @@ -39,6 +42,7 @@ class UserHistoryQueryParams { dateTo: dateTo ?? this.dateTo, action: action ?? this.action, role: role ?? this.role, + search: search ?? this.search, ); } } @@ -60,6 +64,7 @@ class UserHistoryQueryNotifier void setDateTo(String v) => state = state.copyWith(dateTo: v, page: 1); void setAction(String v) => state = state.copyWith(action: v, page: 1); void setRole(String v) => state = state.copyWith(role: v, page: 1); + void setSearch(String v) => state = state.copyWith(search: v, page: 1); void reset() => state = const UserHistoryQueryParams(); } @@ -74,5 +79,6 @@ final userHistoryPageProvider = dateTo: query.dateTo.isEmpty ? null : query.dateTo, action: query.action.isEmpty ? null : query.action, role: query.role.isEmpty ? null : query.role, + search: query.search.isEmpty ? null : query.search, ); }); diff --git a/frontend/lib/ui/providers/dashboard_provider.dart b/frontend/lib/ui/providers/dashboard_provider.dart index 5a7bf4c..b7a6d94 100644 --- a/frontend/lib/ui/providers/dashboard_provider.dart +++ b/frontend/lib/ui/providers/dashboard_provider.dart @@ -68,8 +68,11 @@ class ActivityQueryParams { this.dateTo = '', this.action = '', this.entityTypes = '', + this.search = '', }); + final String search; + ActivityQueryParams copyWith({ int? page, int? limit, @@ -77,6 +80,7 @@ class ActivityQueryParams { String? dateTo, String? action, String? entityTypes, + String? search, }) { return ActivityQueryParams( page: page ?? this.page, @@ -85,6 +89,7 @@ class ActivityQueryParams { dateTo: dateTo ?? this.dateTo, action: action ?? this.action, entityTypes: entityTypes ?? this.entityTypes, + search: search ?? this.search, ); } } @@ -103,6 +108,7 @@ class ActivityQueryNotifier extends AutoDisposeNotifier { void setDateFrom(String v) => state = state.copyWith(dateFrom: v, page: 1); void setDateTo(String v) => state = state.copyWith(dateTo: v, page: 1); void setAction(String v) => state = state.copyWith(action: v, page: 1); + void setSearch(String v) => state = state.copyWith(search: v, page: 1); void setEntityTypes(String v) => state = state.copyWith(entityTypes: v, page: 1); void reset() => state = const ActivityQueryParams(); @@ -121,6 +127,7 @@ final recentActivitiesPageProvider = dateFrom: query.dateFrom.isEmpty ? null : query.dateFrom, dateTo: query.dateTo.isEmpty ? null : query.dateTo, action: query.action.isEmpty ? null : query.action, + search: query.search.isEmpty ? null : query.search, // Teachers only see student and document activities entityTypes: isTeacher ? 'student,document' diff --git a/frontend/lib/ui/providers/reports_provider.dart b/frontend/lib/ui/providers/reports_provider.dart index 81d5c67..d0446cb 100644 --- a/frontend/lib/ui/providers/reports_provider.dart +++ b/frontend/lib/ui/providers/reports_provider.dart @@ -157,6 +157,10 @@ class SelectedStatusesNotifier extends Notifier> { void selectAll() => state = {'enrolled', 'inactive', 'dropped', 'graduated', 'transferred'}; + + void setStatuses(Set statuses) => state = statuses; + + void clear() => state = {}; } // KPI stats & compliance data — re-fetches when any filter changes diff --git a/frontend/lib/ui/providers/theme_provider.dart b/frontend/lib/ui/providers/theme_provider.dart index fec890b..3e63c77 100644 --- a/frontend/lib/ui/providers/theme_provider.dart +++ b/frontend/lib/ui/providers/theme_provider.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -11,14 +11,14 @@ const _kThemeModeKey = 'theme_mode'; class ThemeModeNotifier extends Notifier { @override ThemeMode build() { - // Initial load is synchronous — default to system until async read completes. + // Initial load is synchronous — default to light mode until async read completes. _loadFromPrefs(); - return ThemeMode.system; + return ThemeMode.light; } Future _loadFromPrefs() async { final prefs = await SharedPreferences.getInstance(); - final stored = prefs.getString(_kThemeModeKey) ?? 'system'; + final stored = prefs.getString(_kThemeModeKey) ?? 'light'; state = _fromString(stored); } diff --git a/frontend/lib/ui/screens/archives/archives_screen.dart b/frontend/lib/ui/screens/archives/archives_screen.dart index c9994a5..92890b9 100644 --- a/frontend/lib/ui/screens/archives/archives_screen.dart +++ b/frontend/lib/ui/screens/archives/archives_screen.dart @@ -1796,12 +1796,12 @@ class _ArchivesScreenState extends ConsumerState children: [ Icon( Icons.folder_special_rounded, - size: isMobile ? 38 : 46, + size: isMobile ? 48 : 56, color: Colors.deepOrange, ), SizedBox(height: isMobile ? 6 : 8), Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), + padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( studentName, textAlign: TextAlign.center, @@ -1815,17 +1815,24 @@ class _ArchivesScreenState extends ConsumerState ), ), ), - const SizedBox(height: 4), - _buildStudentStatusChip( - folder.studentStatus ?? 'Archived', - ), - const SizedBox(height: 2), - Text( - '${folder.documentCount ?? 0} docs', - style: TextStyle( - fontSize: 11, - color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, - ), + const SizedBox(height: 6), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildStudentStatusChip( + folder.studentStatus ?? 'Archived', + ), + const SizedBox(width: 4), + Text( + '(${folder.documentCount ?? 0})', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + ), + ), + ], ), ], ), diff --git a/frontend/lib/ui/screens/audit_trail/audit_trail_screen.dart b/frontend/lib/ui/screens/audit_trail/audit_trail_screen.dart new file mode 100644 index 0000000..7785926 --- /dev/null +++ b/frontend/lib/ui/screens/audit_trail/audit_trail_screen.dart @@ -0,0 +1,1133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:calendar_date_picker2/calendar_date_picker2.dart'; +import '../../../core/constants/app_colors.dart'; +import '../../../core/utils/date_utils.dart' as pht; +import '../../providers/dashboard_provider.dart'; +import '../../providers/activity_provider.dart'; +import '../../shared/modals/view_activity_modal.dart'; +import '../../shared/widgets/app_pagination.dart'; + +enum AuditTab { recentActivities, userHistory } + +class AuditTrailScreen extends ConsumerStatefulWidget { + final String userRole; + final AuditTab initialTab; + + const AuditTrailScreen({ + super.key, + required this.userRole, + this.initialTab = AuditTab.recentActivities, + }); + + @override + ConsumerState createState() => _AuditTrailScreenState(); +} + +class _AuditTrailScreenState extends ConsumerState { + late AuditTab _selectedTab; + final TextEditingController _searchController = TextEditingController(); + + DateTime? _fromDate; + DateTime? _toDate; + + // Action chips: All, Add, Update, Archive, Delete + static const List _actionFilters = [ + 'All', + 'Add', + 'Update', + 'Archive', + 'Delete', + ]; + + @override + void initState() { + super.initState(); + _selectedTab = widget.userRole == 'teacher' + ? AuditTab.recentActivities + : widget.initialTab; + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _clearFilters() { + setState(() { + _fromDate = null; + _toDate = null; + _searchController.clear(); + }); + if (_selectedTab == AuditTab.recentActivities) { + ref.read(activityQueryProvider.notifier).reset(); + } else { + ref.read(userHistoryQueryProvider.notifier).reset(); + } + } + + Future _pickDateRange(BuildContext context) async { + final values = await showCalendarDatePicker2Dialog( + context: context, + config: CalendarDatePicker2WithActionButtonsConfig( + calendarType: CalendarDatePicker2Type.range, + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 1)), + selectedDayHighlightColor: AppColors.primaryGreen, + ), + dialogSize: const Size(325, 400), + value: [?_fromDate, ?_toDate], + borderRadius: BorderRadius.circular(16), + ); + + if (values != null && values.isNotEmpty) { + setState(() { + _fromDate = values[0]; + _toDate = values.length > 1 ? values[1] : values[0]; + }); + + final fromStr = _fromDate != null + ? "${_fromDate!.year}-${_fromDate!.month.toString().padLeft(2, '0')}-${_fromDate!.day.toString().padLeft(2, '0')}" + : ''; + final toStr = _toDate != null + ? "${_toDate!.year}-${_toDate!.month.toString().padLeft(2, '0')}-${_toDate!.day.toString().padLeft(2, '0')}" + : ''; + + if (_selectedTab == AuditTab.recentActivities) { + ref.read(activityQueryProvider.notifier).setDateFrom(fromStr); + ref.read(activityQueryProvider.notifier).setDateTo(toStr); + } else { + ref.read(userHistoryQueryProvider.notifier).setDateFrom(fromStr); + ref.read(userHistoryQueryProvider.notifier).setDateTo(toStr); + } + } + } + + Color _getActionColor(String action) { + final act = action.toUpperCase(); + if (act.contains('CREATE') || act.contains('ADD')) { + return const Color(0xFF10B981); // Emerald Green + } + if (act.contains('UPDATE')) { + return const Color(0xFF3B82F6); // Blue + } + if (act.contains('ARCHIVE') || act.contains('INACTIVE')) { + return const Color(0xFFF59E0B); // Amber / Orange + } + if (act.contains('DELETE') || act.contains('DROP')) { + return const Color(0xFFEF4444); // Red + } + return AppColors.primaryGreen; + } + + IconData _getActionIcon(String action) { + final act = action.toUpperCase(); + if (act.contains('CREATE') || act.contains('ADD')) { + return Icons.add_circle_outline_rounded; + } + if (act.contains('UPDATE')) { + return Icons.edit_note_rounded; + } + if (act.contains('ARCHIVE') || act.contains('INACTIVE')) { + return Icons.archive_outlined; + } + if (act.contains('DELETE')) { + return Icons.delete_outline_rounded; + } + return Icons.history_rounded; + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final isMobile = MediaQuery.of(context).size.width < 768; + final isAdmin = widget.userRole == 'admin'; + + final actQuery = ref.watch(activityQueryProvider); + final usrQuery = ref.watch(userHistoryQueryProvider); + + final currentActionFilter = _selectedTab == AuditTab.recentActivities + ? (actQuery.action.isEmpty ? 'All' : actQuery.action) + : (usrQuery.action.isEmpty ? 'All' : usrQuery.action); + + final hasActiveFilters = (_selectedTab == AuditTab.recentActivities + ? (actQuery.action.isNotEmpty || + actQuery.entityTypes.isNotEmpty || + actQuery.search.isNotEmpty || + actQuery.dateFrom.isNotEmpty) + : (usrQuery.action.isNotEmpty || + usrQuery.role.isNotEmpty || + usrQuery.search.isNotEmpty || + usrQuery.dateFrom.isNotEmpty)) || + _fromDate != null || + _toDate != null; + + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Top App Header & Controls ───────────────────────────── + Container( + padding: EdgeInsets.fromLTRB( + isMobile ? 16 : 24, + isMobile ? 16 : 20, + isMobile ? 16 : 24, + 12, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor.withValues(alpha: 0.1), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Row: Title and Refresh / Date Range Buttons + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primaryGreen.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.manage_history_rounded, + color: AppColors.primaryGreen, + size: 24, + ), + ), + const SizedBox(width: 12), + Text( + 'Audit Trail & History', + style: TextStyle( + fontSize: isMobile ? 20 : 24, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ), + if (!isMobile) ...[ + const SizedBox(height: 4), + Text( + 'Track detailed operation history, student/document activities, and user events.', + style: TextStyle( + fontSize: 13, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + ), + ], + ], + ), + Row( + children: [ + // Date Range Filter Button + OutlinedButton.icon( + onPressed: () => _pickDateRange(context), + icon: Icon( + Icons.calendar_today_rounded, + size: 16, + color: (_fromDate != null || _toDate != null) + ? AppColors.primaryGreen + : null, + ), + label: Text( + _fromDate != null && _toDate != null + ? '${_fromDate!.month}/${_fromDate!.day} - ${_toDate!.month}/${_toDate!.day}' + : (_fromDate != null + ? 'From ${_fromDate!.month}/${_fromDate!.day}' + : 'Date Range'), + style: TextStyle( + fontSize: 13, + fontWeight: (_fromDate != null || _toDate != null) + ? FontWeight.bold + : FontWeight.normal, + color: (_fromDate != null || _toDate != null) + ? AppColors.primaryGreen + : null, + ), + ), + style: OutlinedButton.styleFrom( + side: BorderSide( + color: (_fromDate != null || _toDate != null) + ? AppColors.primaryGreen + : Theme.of(context).dividerColor, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + ), + ), + const SizedBox(width: 8), + // Refresh Button + IconButton( + tooltip: 'Refresh', + icon: const Icon(Icons.refresh_rounded), + onPressed: () { + if (_selectedTab == AuditTab.recentActivities) { + ref.invalidate(recentActivitiesPageProvider); + } else { + ref.invalidate(userHistoryPageProvider); + } + }, + ), + ], + ), + ], + ), + const SizedBox(height: 16), + + // Tab switcher (Recent Activities vs User History) + if (isAdmin) + Container( + height: 42, + decoration: BoxDecoration( + color: isDark + ? Colors.black.withValues(alpha: 0.25) + : Colors.grey.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildTabItem( + label: 'Recent Activities', + icon: Icons.list_alt_rounded, + tab: AuditTab.recentActivities, + ), + _buildTabItem( + label: 'User History', + icon: Icons.account_circle_outlined, + tab: AuditTab.userHistory, + ), + ], + ), + ), + + const SizedBox(height: 14), + + // Row: Search field and Action Filter Chips + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 12, + runSpacing: 10, + children: [ + // Search box + SizedBox( + width: isMobile ? double.infinity : 280, + height: 40, + child: TextField( + controller: _searchController, + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + hintText: _selectedTab == AuditTab.recentActivities + ? 'Search description or user...' + : 'Search user or admin...', + hintStyle: TextStyle( + fontSize: 13, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.45), + ), + prefixIcon: const Icon(Icons.search_rounded, size: 18), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear_rounded, size: 16), + onPressed: () { + _searchController.clear(); + if (_selectedTab == AuditTab.recentActivities) { + ref + .read(activityQueryProvider.notifier) + .setSearch(''); + } else { + ref + .read(userHistoryQueryProvider.notifier) + .setSearch(''); + } + }, + ) + : null, + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + filled: true, + fillColor: isDark + ? AppColors.darkSurfaceCard + : Colors.grey.withValues(alpha: 0.08), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: Theme.of(context).dividerColor.withValues(alpha: 0.2), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: Theme.of(context).dividerColor.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide( + color: AppColors.primaryGreen, + width: 1.5, + ), + ), + ), + onSubmitted: (value) { + if (_selectedTab == AuditTab.recentActivities) { + ref + .read(activityQueryProvider.notifier) + .setSearch(value); + } else { + ref + .read(userHistoryQueryProvider.notifier) + .setSearch(value); + } + }, + ), + ), + + // Action filter chips: All, Add, Update, Archive, Delete + Row( + mainAxisSize: MainAxisSize.min, + children: _actionFilters.map((actionName) { + final isSelected = currentActionFilter.toLowerCase() == + actionName.toLowerCase() || + (actionName == 'All' && + (currentActionFilter == 'All' || + currentActionFilter.isEmpty)); + + Color chipColor = AppColors.primaryGreen; + if (actionName == 'Add') { + chipColor = const Color(0xFF10B981); + } else if (actionName == 'Update') { + chipColor = const Color(0xFF3B82F6); + } else if (actionName == 'Archive') { + chipColor = const Color(0xFFF59E0B); + } else if (actionName == 'Delete') { + chipColor = const Color(0xFFEF4444); + } + + return Padding( + padding: const EdgeInsets.only(right: 6), + child: FilterChip( + label: Text(actionName), + selected: isSelected, + showCheckmark: false, + selectedColor: chipColor.withValues(alpha: 0.18), + backgroundColor: isDark + ? AppColors.darkSurfaceCard + : Colors.grey.withValues(alpha: 0.08), + side: BorderSide( + color: isSelected + ? chipColor + : Theme.of(context) + .dividerColor + .withValues(alpha: 0.2), + width: isSelected ? 1.5 : 1, + ), + labelStyle: TextStyle( + fontSize: 12, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.w500, + color: isSelected + ? chipColor + : Theme.of(context).colorScheme.onSurface, + ), + onSelected: (_) { + final target = actionName == 'All' ? '' : actionName; + if (_selectedTab == AuditTab.recentActivities) { + ref + .read(activityQueryProvider.notifier) + .setAction(target); + } else { + ref + .read(userHistoryQueryProvider.notifier) + .setAction(target); + } + }, + ), + ); + }).toList(), + ), + + // Clear filters button + if (hasActiveFilters) + TextButton.icon( + onPressed: _clearFilters, + icon: const Icon(Icons.clear_all_rounded, size: 18), + label: const Text('Reset', style: TextStyle(fontSize: 12)), + style: TextButton.styleFrom( + foregroundColor: Colors.redAccent, + ), + ), + ], + ), + ], + ), + ), + + // ── Main Content Area ───────────────────────────────────── + Expanded( + child: _selectedTab == AuditTab.recentActivities + ? _buildRecentActivitiesTab(isDark, isMobile) + : _buildUserHistoryTab(isDark, isMobile), + ), + ], + ), + ), + ); + } + + Widget _buildTabItem({ + required String label, + required IconData icon, + required AuditTab tab, + }) { + final isSelected = _selectedTab == tab; + return GestureDetector( + onTap: () { + if (_selectedTab != tab) { + setState(() { + _selectedTab = tab; + _searchController.clear(); + }); + } + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: isSelected ? AppColors.primaryGreen : Colors.transparent, + borderRadius: BorderRadius.circular(9), + boxShadow: isSelected + ? [ + BoxShadow( + color: AppColors.primaryGreen.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ] + : null, + ), + child: Row( + children: [ + Icon( + icon, + size: 16, + color: isSelected + ? Colors.white + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, + color: isSelected + ? Colors.white + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ); + } + + // ══════════════════════════════════════════════════════════════════ + // TAB 1: RECENT ACTIVITIES + // ══════════════════════════════════════════════════════════════════ + Widget _buildRecentActivitiesTab(bool isDark, bool isMobile) { + final activitiesAsync = ref.watch(recentActivitiesPageProvider); + + return activitiesAsync.when( + loading: () => const Center( + child: CircularProgressIndicator(color: AppColors.primaryGreen), + ), + error: (err, stack) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline_rounded, color: Colors.redAccent, size: 48), + const SizedBox(height: 12), + Text( + 'Failed to load activities', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 6), + Text( + err.toString(), + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: Colors.grey), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => ref.invalidate(recentActivitiesPageProvider), + icon: const Icon(Icons.refresh), + label: const Text('Try Again'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ), + data: (data) { + if (data.activities.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.history_toggle_off_rounded, + size: 64, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + ), + const SizedBox(height: 16), + Text( + 'No matching activity logs found', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 6), + Text( + 'Try adjusting your search, action, or date filters.', + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), + ); + } + + return Column( + children: [ + Expanded( + child: ListView.separated( + padding: EdgeInsets.symmetric( + horizontal: isMobile ? 16 : 24, + vertical: 16, + ), + itemCount: data.activities.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final item = data.activities[index]; + final actionColor = _getActionColor(item.action); + final actionIcon = _getActionIcon(item.action); + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + ViewActivityModal.show( + context: context, + title: '${item.entityType.toUpperCase()} - ${item.action}', + description: item.description, + date: pht.formatDateTime(item.createdAt), + performedBy: item.performedBy ?? item.username ?? 'System', + action: item.action, + actionColor: actionColor, + icon: actionIcon, + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceCard : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).dividerColor.withValues(alpha: 0.12), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Action icon avatar + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: actionColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(actionIcon, color: actionColor, size: 20), + ), + const SizedBox(width: 14), + // Content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + // Action Badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: actionColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + item.action.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: actionColor, + ), + ), + ), + const SizedBox(width: 8), + // Entity Type Badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.grey.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + item.entityType.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + ), + ), + ), + const Spacer(), + // Timestamp + Text( + pht.formatDateTime(item.createdAt), + style: TextStyle( + fontSize: 11, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), + ), + ), + ], + ), + const SizedBox(height: 8), + // Description + Text( + item.description, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 6), + // Performed By + Row( + children: [ + Icon( + Icons.person_outline_rounded, + size: 13, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), + ), + const SizedBox(width: 4), + Text( + 'By: ${item.performedBy ?? item.username ?? 'System'}', + style: TextStyle( + fontSize: 12, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + // Pagination Footer + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border( + top: BorderSide( + color: Theme.of(context).dividerColor.withValues(alpha: 0.1), + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Total: ${data.total} activities', + style: TextStyle( + fontSize: 12, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + ), + AppPagination( + currentPage: data.page, + totalPages: data.totalPages, + onPageChanged: (newPage) { + ref.read(activityQueryProvider.notifier).setPage(newPage); + }, + ), + ], + ), + ), + ], + ); + }, + ); + } + + // ══════════════════════════════════════════════════════════════════ + // TAB 2: USER HISTORY + // ══════════════════════════════════════════════════════════════════ + Widget _buildUserHistoryTab(bool isDark, bool isMobile) { + final historyAsync = ref.watch(userHistoryPageProvider); + + return historyAsync.when( + loading: () => const Center( + child: CircularProgressIndicator(color: AppColors.primaryGreen), + ), + error: (err, stack) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline_rounded, color: Colors.redAccent, size: 48), + const SizedBox(height: 12), + Text( + 'Failed to load user history', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 6), + Text( + err.toString(), + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: Colors.grey), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => ref.invalidate(userHistoryPageProvider), + icon: const Icon(Icons.refresh), + label: const Text('Try Again'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ), + data: (data) { + if (data.history.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.person_search_rounded, + size: 64, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + ), + const SizedBox(height: 16), + Text( + 'No matching user history records', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 6), + Text( + 'Try adjusting your search, action, or date filters.', + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), + ); + } + + return Column( + children: [ + Expanded( + child: ListView.separated( + padding: EdgeInsets.symmetric( + horizontal: isMobile ? 16 : 24, + vertical: 16, + ), + itemCount: data.history.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final item = data.history[index]; + final actionColor = _getActionColor(item.action); + final actionIcon = _getActionIcon(item.action); + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + ViewActivityModal.show( + context: context, + title: 'User ${item.action.toUpperCase()}', + description: + 'User @${item.username} (${item.fullName.isNotEmpty ? item.fullName : 'No Name'}) with role "${item.role}" was ${item.action}.', + date: pht.formatDateTime(item.createdAt), + performedBy: item.performedByName ?? + item.performedByUsername ?? + 'System', + action: item.action, + actionColor: actionColor, + icon: actionIcon, + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceCard : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).dividerColor.withValues(alpha: 0.12), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Action Avatar + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: actionColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(actionIcon, color: actionColor, size: 20), + ), + const SizedBox(width: 14), + // Content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + // Action Badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: actionColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + item.action.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: actionColor, + ), + ), + ), + const SizedBox(width: 8), + // Role Badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.grey.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + item.role.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + ), + ), + ), + const Spacer(), + // Timestamp + Text( + pht.formatDateTime(item.createdAt), + style: TextStyle( + fontSize: 11, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), + ), + ), + ], + ), + const SizedBox(height: 8), + // User info + Text( + item.fullName.isNotEmpty + ? '${item.fullName} (@${item.username})' + : '@${item.username}', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 6), + // Performed By + Row( + children: [ + Icon( + Icons.person_outline_rounded, + size: 13, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), + ), + const SizedBox(width: 4), + Text( + 'Performed by: ${item.performedByName ?? item.performedByUsername ?? 'System'}', + style: TextStyle( + fontSize: 12, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + // Pagination Footer + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border( + top: BorderSide( + color: Theme.of(context).dividerColor.withValues(alpha: 0.1), + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Total: ${data.total} entries', + style: TextStyle( + fontSize: 12, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + ), + AppPagination( + currentPage: data.page, + totalPages: data.totalPages, + onPageChanged: (newPage) { + ref.read(userHistoryQueryProvider.notifier).setPage(newPage); + }, + ), + ], + ), + ), + ], + ); + }, + ); + } +} diff --git a/frontend/lib/ui/screens/dashboard/dashboard_screen.dart b/frontend/lib/ui/screens/dashboard/dashboard_screen.dart index 018142e..627b5cb 100644 --- a/frontend/lib/ui/screens/dashboard/dashboard_screen.dart +++ b/frontend/lib/ui/screens/dashboard/dashboard_screen.dart @@ -5,7 +5,6 @@ import 'dart:ui'; import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/utils/date_utils.dart' as pht; import '../../shared/cards/stat_card.dart'; import '../../providers/dashboard_provider.dart'; import '../../providers/auth_provider.dart'; @@ -17,8 +16,6 @@ import '../../providers/notification_provider.dart'; import '../../../domain/entities/dashboard_models.dart'; import '../../../domain/entities/user_model.dart'; -import 'recent_activities_screen.dart'; -import 'user_history_screen.dart'; import '../../shared/menus/profile_dropdown_menu.dart'; import '../../shared/inputs/app_search_bar.dart'; import '../settings/requirements_settings_screen.dart'; @@ -389,8 +386,6 @@ class _DashboardScreenState extends ConsumerState { const SizedBox(height: 32), const DashboardKpisSection(), ], - const SizedBox(height: 32), - _buildHistorySections(data, user), const SizedBox(height: 48), ], ), @@ -1033,474 +1028,5 @@ class _DashboardScreenState extends ConsumerState { }, ); } - - // ── HISTORY SECTIONS ───────────────────────────────────────────── - Widget _buildHistorySections(DashboardData data, UserModel? user) { - final width = MediaQuery.of(context).size.width; - final isMobile = width < 800; - final isAdmin = user?.role == 'admin'; - - Widget buildSection({ - required String title, - required VoidCallback onViewAll, - required Widget listWidget, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - title, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - TextButton.icon( - onPressed: onViewAll, - icon: const Icon(Icons.open_in_new, size: 18), - label: const Text('View All'), - style: TextButton.styleFrom( - foregroundColor: AppColors.primaryGreen, - ), - ), - ], - ), - const SizedBox(height: 12), - listWidget, - ], - ); - } - - final recentActivitiesSection = buildSection( - title: 'Recent Activities', - onViewAll: () => Navigator.of(context) - .push( - MaterialPageRoute(builder: (_) => const RecentActivitiesScreen()), - ) - .then((_) => _handleRefresh()), - listWidget: _buildActivitiesList(data.recentActivities.activities), - ); - - if (!isAdmin) { - return recentActivitiesSection; - } - - final userHistorySection = buildSection( - title: 'User History', - onViewAll: () => Navigator.of(context) - .push(MaterialPageRoute(builder: (_) => const UserHistoryScreen())) - .then((_) => _handleRefresh()), - listWidget: _buildUserHistoryList(data.userHistory?.history ?? []), - ); - - if (isMobile) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - userHistorySection, - const SizedBox(height: 32), - recentActivitiesSection, - ], - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: userHistorySection), - const SizedBox(width: 24), - Expanded(child: recentActivitiesSection), - ], - ); - } - - Widget _buildActivitiesList(List activities) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final isMobileOrAndroid = MediaQuery.of(context).size.width < 800 || - defaultTargetPlatform == TargetPlatform.android; - final displayList = isMobileOrAndroid ? activities.take(5).toList() : activities; - - if (activities.isEmpty) { - return RepaintBoundary( - child: Container( - height: 420, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: isDark ? AppColors.darkSurfaceCard : Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isDark ? AppColors.darkBorder : AppColors.borderLight, - width: 1.0, - ), - boxShadow: isMobileOrAndroid - ? null - : [ - BoxShadow( - color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.03), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: const Center( - child: Text( - 'No recent activities yet.', - style: TextStyle(color: Colors.grey), - ), - ), - ), - ); - } - - return RepaintBoundary( - child: Container( - height: 420, - decoration: BoxDecoration( - color: isDark ? AppColors.darkSurfaceCard : Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isDark ? AppColors.darkBorder : AppColors.borderLight, - width: 1.0, - ), - boxShadow: isMobileOrAndroid - ? null - : [ - BoxShadow( - color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.03), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: ListView.separated( - padding: EdgeInsets.zero, - physics: const ClampingScrollPhysics(), - itemCount: displayList.length, - separatorBuilder: (_, _) => - Divider(height: 1, color: Theme.of(context).dividerColor), - itemBuilder: (context, index) { - final a = displayList[index]; - - return Material( - color: Colors.transparent, - child: InkWell( - mouseCursor: SystemMouseCursors.click, - onTap: () => ViewActivityModal.show( - context: context, - title: a.entityType.toUpperCase(), - description: _formatFriendlyDescription(a.description), - date: pht.formatModalDate(a.createdAt), - performedBy: a.performedBy ?? a.username ?? 'System', - action: a.action, - actionColor: _actionColor(a.action), - icon: _actionIcon(a.action, a.entityType), - ), - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - leading: Icon( - _actionIcon(a.action, a.entityType), - color: _actionColor(a.action), - size: 26, - ), - title: Padding( - padding: const EdgeInsets.only(bottom: 6.0), - child: Align( - alignment: Alignment.centerLeft, - child: _buildActionChip(a.action), - ), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _formatFriendlyDescription(a.description).replaceAll(RegExp(r'[\r\n]+'), ' ').trim(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w500, - fontSize: 13, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 4), - Text( - '${a.performedBy ?? a.username ?? 'System'} · ${_formatDate(a.createdAt)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], - ), - trailing: Icon( - Icons.chevron_right_rounded, - size: 20, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.38), - ), - ), - ), - ); - }, - ), - ), - ); - } - - Widget _buildUserHistoryList(List history) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final isMobileOrAndroid = MediaQuery.of(context).size.width < 800 || - defaultTargetPlatform == TargetPlatform.android; - final displayList = isMobileOrAndroid ? history.take(5).toList() : history; - - if (history.isEmpty) { - return RepaintBoundary( - child: Container( - height: 420, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: isDark ? AppColors.darkSurfaceCard : Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isDark ? AppColors.darkBorder : AppColors.borderLight, - width: 1.0, - ), - boxShadow: isMobileOrAndroid - ? null - : [ - BoxShadow( - color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.03), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: const Center( - child: Text( - 'No user history yet.', - style: TextStyle(color: Colors.grey), - ), - ), - ), - ); - } - - return RepaintBoundary( - child: Container( - height: 420, - decoration: BoxDecoration( - color: isDark ? AppColors.darkSurfaceCard : Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isDark ? AppColors.darkBorder : AppColors.borderLight, - width: 1.0, - ), - boxShadow: isMobileOrAndroid - ? null - : [ - BoxShadow( - color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.03), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: ListView.separated( - padding: EdgeInsets.zero, - physics: const ClampingScrollPhysics(), - itemCount: displayList.length, - separatorBuilder: (_, _) => - Divider(height: 1, color: Theme.of(context).dividerColor), - itemBuilder: (context, index) { - final h = displayList[index]; - final desc = _getUserHistoryDescription(h); - - return Material( - color: Colors.transparent, - child: InkWell( - mouseCursor: SystemMouseCursors.click, - onTap: () { - ViewActivityModal.show( - context: context, - title: 'USER ACTIVITY', - description: desc, - date: pht.formatModalDate(h.createdAt), - performedBy: - h.performedByName ?? h.performedByUsername ?? 'System', - action: h.action, - actionColor: _actionColor(h.action), - icon: _actionIcon(h.action, 'user'), - ); - }, - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - leading: CircleAvatar( - backgroundColor: _actionColor( - h.action, - ).withValues(alpha: 0.1), - child: Text( - _getInitials(h.fullName), - style: TextStyle( - color: _actionColor(h.action), - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ), - title: Padding( - padding: const EdgeInsets.only(bottom: 6.0), - child: Align( - alignment: Alignment.centerLeft, - child: _buildActionChip(h.action), - ), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - desc.replaceAll(RegExp(r'[\r\n]+'), ' ').trim(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w500, - fontSize: 13, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 4), - Text( - '${h.performedByName ?? h.performedByUsername ?? 'System'} · ${_formatDate(h.createdAt)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], - ), - trailing: Icon( - Icons.chevron_right_rounded, - size: 20, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.38), - ), - ), - ), - ); - }, - ), - ), - ); - } - - // ── HELPERS ─────────────────────────────────────────────────────────────── - Color _actionColor(String action) { - final isDark = Theme.of(context).brightness == Brightness.dark; - switch (action.toUpperCase()) { - case 'CREATE': - return isDark ? Colors.green.shade300 : AppColors.primaryGreen; - case 'DELETE': - return isDark ? Colors.red.shade300 : Colors.red; - default: - return isDark ? Colors.blue.shade300 : Colors.blue; - } - } - - IconData _actionIcon(String action, String entityType) { - if (entityType == 'user') { - switch (action.toUpperCase()) { - case 'CREATE': - return Icons.person_add_alt_1_rounded; - case 'DELETE': - return Icons.person_off_rounded; - default: - return Icons.manage_accounts_rounded; - } - } - if (entityType == 'student') { - switch (action.toUpperCase()) { - case 'CREATE': - return Icons.school_rounded; - case 'DELETE': - return Icons.delete_forever_rounded; - default: - return Icons.edit_rounded; - } - } - switch (action.toUpperCase()) { - case 'CREATE': - return Icons.upload_file_rounded; - case 'DELETE': - return Icons.delete_sweep_rounded; - default: - return Icons.description_rounded; - } - } - - Widget _buildActionChip(String action) { - final color = _actionColor(action); - return Text( - action.toUpperCase(), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - color: color, - ), - ); - } - - String _formatDate(String raw) => pht.formatRelative(raw); - - String _formatFriendlyDescription(String desc) { - if (desc.startsWith('BULK OCR CREATE student ')) { - return 'Enrolled student ${desc.substring(24)} via Bulk Document Import'; - } - if (desc.startsWith('DELETE student ')) { - return 'Deleted student record: ${desc.substring(15)}'; - } - if (desc.startsWith('UPDATE enrollment ')) { - return desc.replaceAll(RegExp(r'UPDATE enrollment \d+ student'), 'Updated enrollment for student'); - } - return desc; - } - - String _getUserHistoryDescription(UserHistoryEntry h) { - final actionLower = h.action.toLowerCase(); - if (actionLower == 'created') { - return 'Added user: ${h.fullName} as ${h.role}'; - } else if (actionLower == 'updated') { - return 'Updated user: ${h.fullName}'; - } else if (actionLower == 'deleted') { - return 'Deleted user: ${h.fullName}'; - } else if (actionLower == 'activated') { - return 'Activated user: ${h.fullName}'; - } else if (actionLower == 'deactivated') { - return 'Deactivated user: ${h.fullName}'; - } else if (actionLower == 'reset_password') { - return 'Reset password for user: ${h.fullName}'; - } - return '${h.action.toUpperCase()} User: ${h.fullName}'; - } - - String _getInitials(String name) { - if (name.trim().isEmpty) return '?'; - final parts = name.trim().split(RegExp(r'\s+')); - if (parts.length > 1) { - return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); - } - return parts[0].length > 1 - ? parts[0].substring(0, 2).toUpperCase() - : parts[0].toUpperCase(); - } } + diff --git a/frontend/lib/ui/screens/dashboard/widgets/dashboard_kpis.dart b/frontend/lib/ui/screens/dashboard/widgets/dashboard_kpis.dart index cc8daf7..62116d9 100644 --- a/frontend/lib/ui/screens/dashboard/widgets/dashboard_kpis.dart +++ b/frontend/lib/ui/screens/dashboard/widgets/dashboard_kpis.dart @@ -128,7 +128,7 @@ class _KpisContent extends StatelessWidget { ), const SizedBox(height: 16), - // Bento Block 1: Digitalization Progress (Hero flex: 3) + Activity Bar (flex: 2) + // Bento Block 1: Digitalization Progress (flex: 3) + Status Distribution (flex: 2) if (isWide) IntrinsicHeight( child: Row( @@ -141,7 +141,7 @@ class _KpisContent extends StatelessWidget { const SizedBox(width: 16), Expanded( flex: 2, - child: _ActivityBarCard(entries: kpis.activityByDay), + child: _StatusDistributionCard(entries: kpis.statusDistribution), ), ], ), @@ -151,44 +151,17 @@ class _KpisContent extends StatelessWidget { children: [ _DigitalizationCard(data: kpis.digitalization), const SizedBox(height: 16), - _ActivityBarCard(entries: kpis.activityByDay), + _StatusDistributionCard(entries: kpis.statusDistribution), ], ), const SizedBox(height: 16), - // Bento Block 2: Document Breakdown (Detailed flex: 3) + Status Distribution (Compact flex: 2) - if (isWide) - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: 3, - child: _DocTypePieCard( - entries: kpis.docTypeBreakdown, - docTypeByGrade: kpis.docTypeByGrade, - ), - ), - const SizedBox(width: 16), - Expanded( - flex: 2, - child: _StatusDistributionCard(entries: kpis.statusDistribution), - ), - ], - ), - ) - else - Column( - children: [ - _DocTypePieCard( - entries: kpis.docTypeBreakdown, - docTypeByGrade: kpis.docTypeByGrade, - ), - const SizedBox(height: 16), - _StatusDistributionCard(entries: kpis.statusDistribution), - ], - ), + // Bento Block 2: Document Breakdown (Detailed Full Width) + _DocTypePieCard( + entries: kpis.docTypeBreakdown, + docTypeByGrade: kpis.docTypeByGrade, + ), const SizedBox(height: 16), @@ -465,132 +438,6 @@ class _DonutTile extends StatelessWidget { } } -// ────────────────────────────────────────────────────────────── -// 2. ACTIVITY BAR CHART (last 7 days) -// ────────────────────────────────────────────────────────────── -class _ActivityBarCard extends StatelessWidget { - final List entries; - const _ActivityBarCard({required this.entries}); - - @override - Widget build(BuildContext context) { - final Map> byDay = {}; - final now = DateTime.now(); - for (int i = 6; i >= 0; i--) { - final d = now.subtract(Duration(days: i)); - final key = - '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; - byDay[key] = {'uploads': 0, 'deletes': 0, 'updates': 0}; - } - for (final e in entries) { - if (!byDay.containsKey(e.day)) continue; - if (e.action == 'CREATE') { - byDay[e.day]!['uploads'] = (byDay[e.day]!['uploads'] ?? 0) + e.count; - } else if (e.action == 'DELETE') { - byDay[e.day]!['deletes'] = (byDay[e.day]!['deletes'] ?? 0) + e.count; - } else { - byDay[e.day]!['updates'] = (byDay[e.day]!['updates'] ?? 0) + e.count; - } - } - - final days = byDay.keys.toList(); - double maxY = 1; - for (final v in byDay.values) { - final total = (v['uploads']! + v['deletes']! + v['updates']!).toDouble(); - if (total > maxY) maxY = total; - } - - final barGroups = []; - for (int i = 0; i < days.length; i++) { - final v = byDay[days[i]]!; - barGroups.add(BarChartGroupData( - x: i, - barRods: [ - BarChartRodData( - toY: v['uploads']!.toDouble(), - color: AppColors.primaryGreen, - width: 8, - borderRadius: BorderRadius.circular(4), - ), - BarChartRodData( - toY: v['deletes']!.toDouble(), - color: Colors.red.shade400, - width: 8, - borderRadius: BorderRadius.circular(4), - ), - BarChartRodData( - toY: v['updates']!.toDouble(), - color: Colors.blue.shade400, - width: 8, - borderRadius: BorderRadius.circular(4), - ), - ], - )); - } - - return _ChartCard( - title: 'Recent Activities', - icon: Icons.bar_chart_rounded, - iconColor: Colors.blue.shade600, - subtitle: 'Last 7 days by action type', - child: Column( - children: [ - SizedBox( - height: 160, - child: BarChart( - BarChartData( - maxY: maxY * 1.2, - barGroups: barGroups, - gridData: FlGridData( - show: true, - drawVerticalLine: false, - getDrawingHorizontalLine: (_) => - FlLine(color: Theme.of(context).dividerColor, strokeWidth: 1), - ), - borderData: FlBorderData(show: false), - titlesData: FlTitlesData( - leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: (v, meta) { - final idx = v.toInt(); - if (idx < 0 || idx >= days.length) { - return const SizedBox.shrink(); - } - final parts = days[idx].split('-'); - return Text( - '${parts[1]}/${parts[2]}', - style: TextStyle( - fontSize: 9, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)), - ); - }, - reservedSize: 20, - ), - ), - ), - barTouchData: BarTouchData(enabled: false), - ), - ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _Legend(color: AppColors.primaryGreen, label: 'Uploads'), - const SizedBox(width: 12), - _Legend(color: Colors.red.shade400, label: 'Deletes'), - const SizedBox(width: 12), - _Legend(color: Colors.blue.shade400, label: 'Updates'), - ], - ), - ], - ), - ); - } -} // ────────────────────────────────────────────────────────────── // 3. STATUS DISTRIBUTION @@ -1032,9 +879,8 @@ class _DocTypePieCardState extends State<_DocTypePieCard> } } - Widget buildSelectorBar() { - return Row( - mainAxisSize: MainAxisSize.min, + Widget buildSelectorBar({double? maxWidth}) { + final bar = Row( children: [ if (allDocTypeNames.length > 1) ...[ IconButton( @@ -1142,7 +988,6 @@ class _DocTypePieCardState extends State<_DocTypePieCard> return Align( alignment: Alignment.centerLeft, child: Row( - mainAxisSize: MainAxisSize.min, children: [ if (isJhs || isShs) Container( @@ -1164,7 +1009,7 @@ class _DocTypePieCardState extends State<_DocTypePieCard> ), ), ), - Flexible( + Expanded( child: Text( cleanName, style: TextStyle( @@ -1199,11 +1044,19 @@ class _DocTypePieCardState extends State<_DocTypePieCard> ], ], ); + + if (maxWidth != null) { + return SizedBox(width: maxWidth, child: bar); + } + return bar; } - final screenWidth = MediaQuery.of(context).size.width; - final isMobile = screenWidth < 700; - final isCompactChart = screenWidth < 450; + return LayoutBuilder( + builder: (context, constraints) { + final cardWidth = constraints.maxWidth; + final isNarrowCard = cardWidth < 540; + final isCompactChart = cardWidth < 460; + final isMobile = cardWidth < 520; final chartWidget = SizedBox( height: 130, @@ -1275,18 +1128,16 @@ class _DocTypePieCardState extends State<_DocTypePieCard> decoration: BoxDecoration(color: color, shape: BoxShape.circle), ), const SizedBox(width: 6), - Expanded( - child: Text( - g.gradeLevel, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + Text( + g.gradeLevel, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, ), ), + const SizedBox(width: 8), + const Spacer(), Text( '${g.count} / ${g.totalStudents} (${(pct * 100).toStringAsFixed(1)}%)', style: TextStyle( @@ -1299,6 +1150,9 @@ class _DocTypePieCardState extends State<_DocTypePieCard> .onSurface .withValues(alpha: 0.5), ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, ), ], ), @@ -1341,102 +1195,107 @@ class _DocTypePieCardState extends State<_DocTypePieCard> child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header - if (isMobile) ...[ - Row( - children: [ - Icon(Icons.donut_small_rounded, size: 18, color: Colors.purple.shade600), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Document Breakdown', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: Theme.of(context).colorScheme.onSurface, + // Header + if (isNarrowCard) ...[ + Row( + children: [ + Icon(Icons.donut_small_rounded, size: 18, color: Colors.purple.shade600), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Document Breakdown', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - ), - ], - ), - const SizedBox(height: 4), - Text( - subtitle, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ], ), - ), - const SizedBox(height: 10), - buildSelectorBar(), - ] else ...[ - Row( - children: [ - Icon(Icons.donut_small_rounded, size: 18, color: Colors.purple.shade600), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Document Breakdown', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: Theme.of(context).colorScheme.onSurface, + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 10), + buildSelectorBar(), + ] else ...[ + Row( + children: [ + Icon(Icons.donut_small_rounded, size: 18, color: Colors.purple.shade600), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Document Breakdown', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ), - Text( - subtitle, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + Text( + subtitle, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ), - ], + ], + ), ), - ), - const SizedBox(width: 12), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 280), - child: buildSelectorBar(), - ), - ], - ), - ], - const SizedBox(height: 16), + const SizedBox(width: 12), + buildSelectorBar(maxWidth: (cardWidth * 0.48).clamp(200.0, 270.0)), + ], + ), + ], + const SizedBox(height: 16), - // Content Body - if (selectedGradeList.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: Center( - child: Text('No grade level data available for this document type.', - style: TextStyle(fontSize: 12, color: AppColors.textSecondary)), + // Content Body + if (selectedGradeList.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Center( + child: Text('No grade level data available for this document type.', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary)), + ), + ) + else if (isCompactChart) + Column( + children: [ + Center(child: chartWidget), + const SizedBox(height: 12), + listWidget, + ], + ) + else + Row( + children: [ + chartWidget, + const SizedBox(width: 16), + Expanded(child: listWidget), + ], ), - ) - else if (isCompactChart) - Column( - children: [ - Center(child: chartWidget), - const SizedBox(height: 12), - listWidget, - ], - ) - else - Row( - children: [ - chartWidget, - const SizedBox(width: 16), - Expanded(child: listWidget), - ], - ), - ], + ], + ), ), - ), ); + }, + ); } } @@ -2584,28 +2443,6 @@ class _SectionHeader extends StatelessWidget { } } -class _Legend extends StatelessWidget { - final Color color; - final String label; - const _Legend({required this.color, required this.label}); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration(color: color, shape: BoxShape.circle)), - const SizedBox(width: 4), - Text(label, - style: - TextStyle(fontSize: 10, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)),), - ], - ); - } -} class _KpiSkeleton extends StatelessWidget { const _KpiSkeleton(); diff --git a/frontend/lib/ui/screens/documents/documents_screen.dart b/frontend/lib/ui/screens/documents/documents_screen.dart index ea1532b..7edad30 100644 --- a/frontend/lib/ui/screens/documents/documents_screen.dart +++ b/frontend/lib/ui/screens/documents/documents_screen.dart @@ -1995,12 +1995,12 @@ class _DocumentsScreenState extends ConsumerState children: [ Icon( Icons.folder_rounded, - size: isMobile ? 38 : 46, + size: isMobile ? 48 : 56, color: Colors.orange, ), SizedBox(height: isMobile ? 6 : 8), Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), + padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( folder.name, textAlign: TextAlign.center, @@ -2014,10 +2014,22 @@ class _DocumentsScreenState extends ConsumerState ), ), ), - const SizedBox(height: 4), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: _buildFolderCompletionBadge(folder), + const SizedBox(height: 6), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildStudentStatusChip(folder.studentStatus ?? 'Enrolled'), + const SizedBox(width: 4), + Text( + '(${folder.documentCount ?? 0})', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + ), + ), + ], ), ], ), @@ -2042,6 +2054,43 @@ class _DocumentsScreenState extends ConsumerState ); } + Widget _buildStudentStatusChip(String status) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + final bg = switch (status) { + 'Enrolled' => AppColors.primaryGreen.withValues(alpha: isDark ? 0.25 : 0.10), + 'Graduated' => Colors.blue.withValues(alpha: isDark ? 0.25 : 0.10), + 'Transferred' => Colors.orange.withValues(alpha: isDark ? 0.25 : 0.10), + 'Dropped' => Colors.red.withValues(alpha: isDark ? 0.25 : 0.10), + _ => isDark ? AppColors.darkSurface2 : Colors.grey.shade200, + }; + + final fg = switch (status) { + 'Enrolled' => isDark ? Colors.green.shade300 : AppColors.primaryGreen, + 'Graduated' => isDark ? Colors.blue.shade300 : Colors.blue.shade700, + 'Transferred' => isDark ? Colors.orange.shade300 : Colors.orange.shade800, + 'Dropped' => isDark ? Colors.red.shade300 : Colors.red.shade700, + _ => isDark ? AppColors.darkTextPrimary : Colors.grey.shade700, + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: fg.withValues(alpha: 0.3)), + ), + child: Text( + status, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: fg, + ), + ), + ); + } + Widget _buildFolderCompletionBadge(dynamic folder) { final FolderModel f = folder as FolderModel; final isDark = Theme.of(context).brightness == Brightness.dark; diff --git a/frontend/lib/ui/screens/documents/widgets/student_profile_modal.dart b/frontend/lib/ui/screens/documents/widgets/student_profile_modal.dart index 4a529d0..5a4bd6c 100644 --- a/frontend/lib/ui/screens/documents/widgets/student_profile_modal.dart +++ b/frontend/lib/ui/screens/documents/widgets/student_profile_modal.dart @@ -521,89 +521,168 @@ class StudentProfileModalBody extends ConsumerWidget { ), ], ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: LayoutBuilder( + builder: (context, constraints) { + final isNarrow = constraints.maxWidth < 460 || + Theme.of(context).platform == TargetPlatform.android; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - radius: 30, - backgroundColor: AppColors.primaryGreen, - child: Text( - '${student.firstName?[0] ?? ''}${student.lastName?[0] ?? ''}', - style: const TextStyle( - fontSize: 20, - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + if (isNarrow) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text( - student.profileDisplayName, - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.bold, - color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary, + CircleAvatar( + radius: 26, + backgroundColor: AppColors.primaryGreen, + child: Text( + '${student.firstName?[0] ?? ''}${student.lastName?[0] ?? ''}', + style: const TextStyle( + fontSize: 18, + color: Colors.white, + fontWeight: FontWeight.bold, + ), ), ), - _CopyableLrnButton( - lrn: student.lrn?.toString(), - isDark: isDark, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + student.profileDisplayName, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + _CopyableLrnButton( + lrn: student.lrn?.toString(), + isDark: isDark, + ), + ], + ), + ), + const SizedBox(width: 8), + _buildStatusBadge(student.status), + ], + ), + if (onEditDetails != null && userRole != 'teacher') ...[ + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: TextButton.icon( + onPressed: onEditDetails, + icon: const Icon(Icons.edit_outlined, size: 14, color: AppColors.primaryGreen), + label: const Text( + 'Edit Details', + style: TextStyle( + color: AppColors.primaryGreen, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + style: TextButton.styleFrom( + foregroundColor: AppColors.primaryGreen, + backgroundColor: AppColors.primaryGreen.withValues(alpha: 0.08), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ), + ), + ), + ], + ] else ...[ + Row( + children: [ + CircleAvatar( + radius: 30, + backgroundColor: AppColors.primaryGreen, + child: Text( + '${student.firstName?[0] ?? ''}${student.lastName?[0] ?? ''}', + style: const TextStyle( + fontSize: 20, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + student.profileDisplayName, + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + _CopyableLrnButton( + lrn: student.lrn?.toString(), + isDark: isDark, + ), + ], + ), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + _buildStatusBadge(student.status), + if (onEditDetails != null && userRole != 'teacher') ...[ + const SizedBox(height: 6), + Tooltip( + message: 'Edit Student Details', + child: TextButton.icon( + onPressed: onEditDetails, + icon: const Icon(Icons.edit_outlined, size: 14, color: AppColors.primaryGreen), + label: const Text( + 'Edit Details', + style: TextStyle( + color: AppColors.primaryGreen, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + style: TextButton.styleFrom( + foregroundColor: AppColors.primaryGreen, + backgroundColor: AppColors.primaryGreen.withValues(alpha: 0.08), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ), + ), + ), + ], + ], ), ], ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, + ], + Divider(height: 24, color: isDark ? AppColors.darkBorder : Colors.grey.shade200), + Wrap( + spacing: 20, + runSpacing: 8, children: [ - _buildStatusBadge(student.status), - if (onEditDetails != null && userRole != 'teacher') ...[ - const SizedBox(height: 6), - Tooltip( - message: 'Edit Student Details', - child: TextButton.icon( - onPressed: onEditDetails, - icon: const Icon(Icons.edit_outlined, size: 14, color: AppColors.primaryGreen), - label: const Text( - 'Edit Details', - style: TextStyle( - color: AppColors.primaryGreen, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - style: TextButton.styleFrom( - foregroundColor: AppColors.primaryGreen, - backgroundColor: AppColors.primaryGreen.withValues(alpha: 0.08), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), - ), - ), - ), - ], + _buildInfoItem(context, 'Sex', (student.sex != null && student.sex!.isNotEmpty) ? student.sex! : '-'), + _buildInfoItem(context, 'Birth Date', student.birthDate != null ? _formatDate(student.birthDate!) : '-'), + _build4psItem(context, student.is4ps), ], ), ], - ), - Divider(height: 24, color: isDark ? AppColors.darkBorder : Colors.grey.shade200), - Wrap( - spacing: 20, - runSpacing: 8, - children: [ - _buildInfoItem(context, 'Sex', (student.sex != null && student.sex!.isNotEmpty) ? student.sex! : '-'), - _buildInfoItem(context, 'Birth Date', student.birthDate != null ? _formatDate(student.birthDate!) : '-'), - _build4psItem(context, student.is4ps), - ], - ), - ], + ); + }, ), ); } diff --git a/frontend/lib/ui/screens/reports/reports_screen.dart b/frontend/lib/ui/screens/reports/reports_screen.dart index 5b250b0..590d03b 100644 --- a/frontend/lib/ui/screens/reports/reports_screen.dart +++ b/frontend/lib/ui/screens/reports/reports_screen.dart @@ -788,9 +788,12 @@ class _ReportsScreenState extends ConsumerState { ), ), SizedBox( - width: 175, + width: 130, child: PrimaryButton( label: 'EXPORT', + icon: Icons.file_download_outlined, + fontSize: 13, + height: 40, isLoading: _isExporting, onPressed: () => _handleExportExcel(data), ), @@ -799,6 +802,19 @@ class _ReportsScreenState extends ConsumerState { ); } + final selectedYearId = ref.watch(selectedAcademicYearIdProvider); + final selectedGrade = ref.watch(selectedGradeLevelProvider); + final selectedSection = ref.watch(selectedSectionIdProvider); + final selectedStatus = ref.watch(selectedStatusFilterProvider); + final showOnlyMissingDocs = ref.watch(showOnlyMissingDocsProvider); + + int activeFilterCount = 0; + if (selectedYearId != null) activeFilterCount++; + if (selectedGrade != null) activeFilterCount++; + if (selectedSection != null) activeFilterCount++; + if (selectedStatus != null) activeFilterCount++; + if (showOnlyMissingDocs) activeFilterCount++; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -819,13 +835,47 @@ class _ReportsScreenState extends ConsumerState { ), ), const SizedBox(height: AppSizes.p12), - SizedBox( - width: double.infinity, - child: PrimaryButton( - label: 'EXPORT', - isLoading: _isExporting, - onPressed: () => _handleExportExcel(data), - ), + Row( + children: [ + Expanded( + child: SizedBox( + height: 40, + child: OutlinedButton.icon( + onPressed: () => _showComplianceFilterModal(context), + icon: const Icon(Icons.filter_list_rounded, size: 16), + label: Text( + activeFilterCount > 0 ? 'Filter ($activeFilterCount)' : 'Filter', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold), + ), + style: OutlinedButton.styleFrom( + foregroundColor: activeFilterCount > 0 + ? AppColors.primaryGreen + : (isDark ? AppColors.darkTextPrimary : AppColors.textPrimary), + side: BorderSide( + color: activeFilterCount > 0 + ? AppColors.primaryGreen + : (isDark ? AppColors.darkBorder : Colors.grey.shade300), + width: activeFilterCount > 0 ? 1.5 : 1, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: PrimaryButton( + label: 'EXPORT', + icon: Icons.file_download_outlined, + fontSize: 13, + height: 40, + isLoading: _isExporting, + onPressed: () => _handleExportExcel(data), + ), + ), + ], ), ], ); @@ -888,8 +938,13 @@ class _ReportsScreenState extends ConsumerState { top: Radius.circular(AppSizes.radiusLarge), ) : BorderRadius.circular(AppSizes.radiusLarge), - onTap: () => ref.read(filterPanelExpandedProvider.notifier).state = - !isExpanded, + onTap: () { + if (MediaQuery.of(context).size.width <= 900) { + _showComplianceFilterModal(context); + } else { + ref.read(filterPanelExpandedProvider.notifier).state = !isExpanded; + } + }, child: Padding( padding: const EdgeInsets.symmetric( horizontal: AppSizes.p16, @@ -1918,6 +1973,516 @@ class _ReportsScreenState extends ConsumerState { ); } + Widget _buildRequirementGroupHeader(String title, bool isDark) { + return Padding( + padding: const EdgeInsets.only(top: 10, bottom: 6), + child: Text( + title, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade700, + letterSpacing: 0.3, + ), + ), + ); + } + + Widget _buildMissingRequirementItem(String doc, bool isDark) { + return Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurface2 : Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark ? AppColors.darkBorder : Colors.grey.shade200, + ), + ), + child: Row( + children: [ + Icon( + Icons.cancel_outlined, + size: 16, + color: isDark ? const Color(0xFFD67878) : Colors.red.shade600, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + doc, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'MISSING', + style: TextStyle( + color: Colors.red, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + } + + void _showMissingDocsBottomSheet(BuildContext context, ReportStudent student) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final missingCount = student.missingCount; + final rawStr = student.missingRequirements ?? ''; + final rawList = rawStr + .split(',') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + final jhsDocs = rawList + .where((d) => d.toUpperCase().startsWith('[JHS]')) + .map((d) => d.replaceFirst(RegExp(r'^\[JHS\]\s*', caseSensitive: false), '').trim()) + .toList(); + final shsDocs = rawList + .where((d) => d.toUpperCase().startsWith('[SHS]')) + .map((d) => d.replaceFirst(RegExp(r'^\[SHS\]\s*', caseSensitive: false), '').trim()) + .toList(); + final otherDocs = rawList + .where((d) => + !d.toUpperCase().startsWith('[JHS]') && + !d.toUpperCase().startsWith('[SHS]')) + .toList(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceCard : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: isDark ? Colors.white24 : Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + student.fullName, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'LRN: ${student.lrn} • Grade ${student.gradeLevel ?? "N/A"} - ${student.sectionName ?? "N/A"}', + style: TextStyle( + fontSize: 13, + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + ), + const Divider(height: 1), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: missingCount == 0 + ? (isDark ? const Color(0xFF76BA8A).withValues(alpha: 0.15) : Colors.green.shade50) + : (isDark ? const Color(0xFFD67878).withValues(alpha: 0.15) : Colors.red.shade50), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: missingCount == 0 + ? (isDark ? const Color(0xFF76BA8A).withValues(alpha: 0.4) : Colors.green.shade200) + : (isDark ? const Color(0xFFD67878).withValues(alpha: 0.4) : Colors.red.shade200), + ), + ), + child: Row( + children: [ + Icon( + missingCount == 0 ? Icons.check_circle_rounded : Icons.warning_amber_rounded, + color: missingCount == 0 ? Colors.green.shade700 : Colors.red.shade700, + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + missingCount == 0 + ? 'All Documents Completed' + : '$missingCount Missing Mandatory Document${missingCount > 1 ? "s" : ""}', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: missingCount == 0 ? Colors.green.shade700 : Colors.red.shade700, + ), + ), + const SizedBox(height: 2), + Text( + missingCount == 0 + ? 'All mandatory requirements for this student are complete.' + : 'Required documents pending submission or verification.', + style: TextStyle( + fontSize: 12, + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + if (missingCount > 0) ...[ + Text( + 'MISSING REQUIREMENTS', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + if (jhsDocs.isNotEmpty) ...[ + _buildRequirementGroupHeader('Junior High School (JHS)', isDark), + ...jhsDocs.map((doc) => _buildMissingRequirementItem(doc, isDark)), + ], + if (shsDocs.isNotEmpty) ...[ + _buildRequirementGroupHeader('Senior High School (SHS)', isDark), + ...shsDocs.map((doc) => _buildMissingRequirementItem(doc, isDark)), + ], + if (otherDocs.isNotEmpty) ...[ + _buildRequirementGroupHeader('General Requirements', isDark), + ...otherDocs.map((doc) => _buildMissingRequirementItem(doc, isDark)), + ], + ], + ], + ), + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + child: SizedBox( + width: double.infinity, + child: PrimaryButton( + label: 'VIEW STUDENT PROFILE', + onPressed: () { + Navigator.pop(ctx); + showStudentProfileModal( + context, + studentId: student.id, + userRole: widget.userRole, + hideEnrollmentActions: true, + ); + }, + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + void _showComplianceFilterModal(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return Consumer( + builder: (modalCtx, ref, _) { + final yearsAsync = ref.watch(academicYearsProvider); + final selectedYearId = ref.watch(selectedAcademicYearIdProvider); + final selectedGrade = ref.watch(selectedGradeLevelProvider); + final selectedSection = ref.watch(selectedSectionIdProvider); + final selectedStatus = ref.watch(selectedStatusFilterProvider); + final sections = ref.watch(filteredSectionsProvider); + final showOnlyMissingDocs = ref.watch(showOnlyMissingDocsProvider); + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceCard : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: isDark ? Colors.white24 : Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 12, 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Filter Compliance & Analytics', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + 'Filter students by school year, grade, section, or status', + style: TextStyle( + fontSize: 12, + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + ), + const Divider(height: 1), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + yearsAsync.when( + skipLoadingOnReload: true, + loading: () => const LinearProgressIndicator(), + error: (_, _) => const SizedBox.shrink(), + data: (yearsList) { + final sortedYears = List.from(yearsList) + ..sort((a, b) => b.yearRange.compareTo(a.yearRange)); + return DropdownButtonFormField( + isExpanded: true, + initialValue: selectedYearId, + decoration: _filterDecoration('School Year'), + items: [ + const DropdownMenuItem( + value: null, + child: Text('All Years'), + ), + ...sortedYears.map( + (y) => DropdownMenuItem( + value: y.id, + child: Text(y.yearRange), + ), + ), + ], + onChanged: (val) { + ref.read(selectedAcademicYearIdProvider.notifier).select(val); + setState(() => _currentPage = 0); + }, + ); + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + isExpanded: true, + initialValue: selectedGrade, + decoration: _filterDecoration('Grade Level'), + items: [ + const DropdownMenuItem( + value: null, + child: Text('All Grades'), + ), + ...[7, 8, 9, 10, 11, 12].map( + (g) => DropdownMenuItem( + value: g, + child: Text('Grade $g'), + ), + ), + ], + onChanged: (val) { + ref.read(selectedGradeLevelProvider.notifier).state = val; + setState(() => _currentPage = 0); + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + isExpanded: true, + initialValue: selectedSection, + decoration: _filterDecoration('Section'), + items: [ + const DropdownMenuItem( + value: null, + child: Text('All Sections'), + ), + ...sections.map( + (sec) => DropdownMenuItem( + value: (sec['id'] as num).toInt(), + child: Text(sec['name'] as String), + ), + ), + ], + onChanged: (val) { + ref.read(selectedSectionIdProvider.notifier).state = val; + setState(() => _currentPage = 0); + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + isExpanded: true, + initialValue: selectedStatus, + decoration: _filterDecoration('Student Status'), + items: const [ + DropdownMenuItem( + value: null, + child: Text('All Statuses'), + ), + DropdownMenuItem(value: 'Enrolled', child: Text('Enrolled')), + DropdownMenuItem(value: 'Graduated', child: Text('Graduated')), + DropdownMenuItem(value: 'Transferred', child: Text('Transferred')), + DropdownMenuItem(value: 'Dropped', child: Text('Dropped')), + DropdownMenuItem(value: 'Inactive', child: Text('Inactive')), + ], + onChanged: (val) { + ref.read(selectedStatusFilterProvider.notifier).state = val; + setState(() => _currentPage = 0); + }, + ), + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurface2 : Colors.grey.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isDark ? AppColors.darkBorder : Colors.grey.shade200, + ), + ), + child: SwitchListTile( + title: const Text( + 'Missing Documents Only', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14), + ), + subtitle: const Text( + 'Filter to only show students who have unsubmitted mandatory documents', + style: TextStyle(fontSize: 12), + ), + value: showOnlyMissingDocs, + activeTrackColor: AppColors.primaryGreen, + onChanged: (val) { + ref.read(showOnlyMissingDocsProvider.notifier).state = val; + setState(() => _currentPage = 0); + }, + ), + ), + ], + ), + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + ref.read(selectedAcademicYearIdProvider.notifier).select(null); + ref.read(selectedGradeLevelProvider.notifier).state = null; + ref.read(selectedSectionIdProvider.notifier).state = null; + ref.read(selectedStatusFilterProvider.notifier).state = null; + ref.read(showOnlyMissingDocsProvider.notifier).state = false; + setState(() => _currentPage = 0); + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Reset All'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: PrimaryButton( + label: 'Apply Filters', + onPressed: () => Navigator.pop(ctx), + ), + ), + ], + ), + ), + ), + ], + ), + ); + }, + ); + }, + ); + } + Widget _buildHorizontalScrollHint(BuildContext context, {String text = 'Scroll horizontally to view more'}) { final isDark = Theme.of(context).brightness == Brightness.dark; final hintColor = isDark ? const Color(0xFF94A3B8) : const Color(0xFF64748B); @@ -2376,51 +2941,42 @@ class _ReportsScreenState extends ConsumerState { context: context, missingCount: student.missingCount, missingRequirementsStr: student.missingRequirements, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: student.missingCount > 0 - ? (isDark - ? const Color(0xFFD67878).withValues(alpha: 0.14) - : Colors.red.shade50) - : (isDark - ? const Color(0xFF76BA8A).withValues(alpha: 0.14) - : Colors.green.shade50), - borderRadius: BorderRadius.circular(12), - border: Border.all( + child: InkWell( + onTap: () => _showMissingDocsBottomSheet(context, student), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( color: student.missingCount > 0 ? (isDark - ? const Color(0xFFD67878).withValues(alpha: 0.3) - : Colors.red.shade200) + ? const Color(0xFFD67878).withValues(alpha: 0.14) + : Colors.red.shade50) : (isDark - ? const Color(0xFF76BA8A).withValues(alpha: 0.3) - : Colors.green.shade200), - width: 0.8, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - student.missingCount > 0 - ? Icons.info_outline_rounded - : Icons.check_circle_outline_rounded, - size: 13, + ? const Color(0xFF76BA8A).withValues(alpha: 0.14) + : Colors.green.shade50), + borderRadius: BorderRadius.circular(12), + border: Border.all( color: student.missingCount > 0 ? (isDark - ? const Color(0xFFD67878) - : Colors.red.shade700) + ? const Color(0xFFD67878).withValues(alpha: 0.3) + : Colors.red.shade200) : (isDark - ? const Color(0xFF76BA8A) - : Colors.green.shade700), + ? const Color(0xFF76BA8A).withValues(alpha: 0.3) + : Colors.green.shade200), + width: 0.8, ), - const SizedBox(width: 4), - Text( - student.missingCount.toString(), - style: TextStyle( + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + student.missingCount > 0 + ? Icons.info_outline_rounded + : Icons.check_circle_outline_rounded, + size: 13, color: student.missingCount > 0 ? (isDark ? const Color(0xFFD67878) @@ -2428,11 +2984,36 @@ class _ReportsScreenState extends ConsumerState { : (isDark ? const Color(0xFF76BA8A) : Colors.green.shade700), - fontWeight: FontWeight.bold, - fontSize: 12, ), - ), - ], + const SizedBox(width: 4), + Text( + student.missingCount.toString(), + style: TextStyle( + color: student.missingCount > 0 + ? (isDark + ? const Color(0xFFD67878) + : Colors.red.shade700) + : (isDark + ? const Color(0xFF76BA8A) + : Colors.green.shade700), + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + const SizedBox(width: 2), + Icon( + Icons.arrow_drop_down_rounded, + size: 18, + color: student.missingCount > 0 + ? (isDark + ? const Color(0xFFD67878) + : Colors.red.shade700) + : (isDark + ? const Color(0xFF76BA8A) + : Colors.green.shade700), + ), + ], + ), ), ), ), @@ -3204,8 +3785,8 @@ class _ReportsScreenState extends ConsumerState { ? 'All Years' : '${selectedYears.length} Years', icon: Icons.calendar_today_outlined, - onTap: (btnCtx) => _showYearMultiSelectMenu( - btnCtx, + onTap: (btnCtx) => _showYearMultiSelectModal( + context, allYearStrings, selectedYears, ), @@ -3229,8 +3810,8 @@ class _ReportsScreenState extends ConsumerState { .label : '${selectedStatuses.length} Statuses', icon: Icons.people_alt_outlined, - onTap: (btnCtx) => _showStatusMultiSelectMenu( - btnCtx, + onTap: (btnCtx) => _showStatusMultiSelectModal( + context, allStatusOptions, selectedStatuses, ), @@ -3562,287 +4143,358 @@ class _ReportsScreenState extends ConsumerState { ); } - /// Shows a popup multi-select menu for years - void _showYearMultiSelectMenu( + /// Shows a modal bottom sheet checklist for academic years + void _showYearMultiSelectModal( BuildContext context, List allYears, Set selected, ) { - final RenderBox? button = context.findRenderObject() as RenderBox?; - final RenderBox? overlay = - Overlay.maybeOf(context)?.context.findRenderObject() as RenderBox?; - if (button == null || overlay == null) return; - - final RelativeRect position = RelativeRect.fromRect( - Rect.fromPoints( - button.localToGlobal(Offset.zero, ancestor: overlay), - button.localToGlobal( - button.size.bottomRight(Offset.zero), - ancestor: overlay, - ), - ), - Offset.zero & overlay.size, - ); - - showMenu( + final isDark = Theme.of(context).brightness == Brightness.dark; + showModalBottomSheet( context: context, - position: position, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - items: [ - PopupMenuItem( - enabled: false, - padding: EdgeInsets.zero, - child: Consumer( - builder: (ctx, ref, _) { - final currentSelected = ref.watch( - yearlyComparisonSelectedYearsProvider, - ); - final default4 = allYears.length <= 4 - ? allYears.toSet() - : allYears.sublist(allYears.length - 4).toSet(); + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return Consumer( + builder: (bottomSheetCtx, ref, _) { + final currentSelected = ref.watch(yearlyComparisonSelectedYearsProvider); + final default4 = allYears.length <= 4 + ? allYears.toSet() + : allYears.sublist(allYears.length - 4).toSet(); + final activeYearsList = ref.watch(academicYearsProvider).asData?.value ?? []; + final activeYearObj = activeYearsList.firstWhere( + (y) => y.status == 'active', + orElse: () => AcademicYear(id: 0, yearRange: '', status: ''), + ); - return SizedBox( - width: 290, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Select School Years', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 13, - ), - ), - const SizedBox(height: 6), - Row( + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceCard : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: isDark ? Colors.white24 : Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 12, 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextButton( - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: () { - ref - .read( - yearlyComparisonSelectedYearsProvider - .notifier, - ) - .setYears(default4); - }, - child: const Text( - 'Latest 4', - style: TextStyle(fontSize: 12), + Text( + 'Filter Academic Years', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary, ), ), - const SizedBox(width: 4), - TextButton( - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: () { - ref - .read( - yearlyComparisonSelectedYearsProvider - .notifier, - ) - .setYears(allYears.toSet()); - }, - child: const Text( - 'All Years', - style: TextStyle(fontSize: 12), - ), - ), - const Spacer(), - TextButton( - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: () { - ref - .read( - yearlyComparisonSelectedYearsProvider - .notifier, - ) - .clear(); - }, - child: const Text( - 'Clear', - style: TextStyle(fontSize: 12), + const SizedBox(height: 2), + Text( + 'Choose school years to include in comparison', + style: TextStyle( + fontSize: 12, + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, ), ), ], ), - ], - ), + ), + IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () => Navigator.pop(ctx), + ), + ], ), - const Divider(height: 1), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 280), - child: ListView( - shrinkWrap: true, - children: allYears.map((year) { - final isChecked = currentSelected.isEmpty - ? default4.contains(year) - : currentSelected.contains(year); - return CheckboxListTile( - dense: true, - value: isChecked, - title: Text(year, style: const TextStyle(fontSize: 13)), - activeColor: AppColors.primaryGreen, - controlAffinity: ListTileControlAffinity.leading, - onChanged: (val) { - if (currentSelected.isEmpty) { - final updated = Set.from(default4); - if (updated.contains(year)) { - updated.remove(year); - } else { - updated.add(year); - } - ref - .read( - yearlyComparisonSelectedYearsProvider - .notifier, - ) - .setYears(updated); - } else { - ref - .read( - yearlyComparisonSelectedYearsProvider - .notifier, - ) - .toggle(year); - } - }, - ); - }).toList(), - ), - ), - ], - ), - ); - }, - ), - ), - ], - ); - } - - /// Shows a popup multi-select menu for statuses - void _showStatusMultiSelectMenu( - BuildContext context, - List<_StatusOption> options, - Set selected, - ) { - final RenderBox? button = context.findRenderObject() as RenderBox?; - final RenderBox? overlay = - Overlay.maybeOf(context)?.context.findRenderObject() as RenderBox?; - if (button == null || overlay == null) return; - - final RelativeRect position = RelativeRect.fromRect( - Rect.fromPoints( - button.localToGlobal(Offset.zero, ancestor: overlay), - button.localToGlobal( - button.size.bottomRight(Offset.zero), - ancestor: overlay, - ), - ), - Offset.zero & overlay.size, - ); - - showMenu( - context: context, - position: position, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - items: [ - PopupMenuItem( - enabled: false, - padding: EdgeInsets.zero, - child: Consumer( - builder: (ctx, ref, _) { - final currentSelected = ref.watch( - yearlyComparisonSelectedStatusesProvider, - ); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ + ), Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), child: Row( children: [ - const Text( - 'Select Statuses', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 13, - ), + ActionChip( + label: const Text('Latest 4 Years', style: TextStyle(fontSize: 12)), + backgroundColor: AppColors.primaryGreen.withValues(alpha: 0.1), + labelStyle: const TextStyle(color: AppColors.primaryGreen, fontWeight: FontWeight.w600), + side: BorderSide(color: AppColors.primaryGreen.withValues(alpha: 0.3)), + onPressed: () { + ref.read(yearlyComparisonSelectedYearsProvider.notifier).setYears(default4); + }, + ), + const SizedBox(width: 8), + ActionChip( + label: const Text('All Years', style: TextStyle(fontSize: 12)), + onPressed: () { + ref.read(yearlyComparisonSelectedYearsProvider.notifier).setYears(allYears.toSet()); + }, ), const Spacer(), TextButton( onPressed: () { - ref - .read( - yearlyComparisonSelectedStatusesProvider - .notifier, - ) - .selectAll(); + ref.read(yearlyComparisonSelectedYearsProvider.notifier).clear(); }, - child: const Text( - 'All', - style: TextStyle(fontSize: 12), - ), + child: const Text('Clear', style: TextStyle(fontSize: 12)), ), ], ), ), const Divider(height: 1), - ...options.map((opt) { - final isChecked = currentSelected.contains(opt.key); - return CheckboxListTile( - dense: true, - value: isChecked, - title: Row( - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: opt.color, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 8), - Text(opt.label, style: const TextStyle(fontSize: 13)), - ], + Flexible( + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: allYears.length, + separatorBuilder: (_, _) => Divider( + height: 1, + color: isDark ? AppColors.darkBorder.withValues(alpha: 0.5) : Colors.grey.shade100, ), - activeColor: AppColors.primaryGreen, - controlAffinity: ListTileControlAffinity.leading, - onChanged: (val) { - ref - .read( - yearlyComparisonSelectedStatusesProvider.notifier, - ) - .toggle(opt.key); + itemBuilder: (context, idx) { + final year = allYears[idx]; + final isChecked = currentSelected.isEmpty + ? default4.contains(year) + : currentSelected.contains(year); + final isActiveSY = year == activeYearObj.yearRange; + + return CheckboxListTile( + value: isChecked, + activeColor: AppColors.primaryGreen, + title: Row( + children: [ + Text( + year, + style: TextStyle( + fontWeight: isChecked ? FontWeight.bold : FontWeight.w500, + fontSize: 14, + ), + ), + if (isActiveSY) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.primaryGreen.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'ACTIVE', + style: TextStyle( + color: AppColors.primaryGreen, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + onChanged: (val) { + if (currentSelected.isEmpty) { + final updated = Set.from(default4); + if (updated.contains(year)) { + updated.remove(year); + } else { + updated.add(year); + } + ref.read(yearlyComparisonSelectedYearsProvider.notifier).setYears(updated); + } else { + ref.read(yearlyComparisonSelectedYearsProvider.notifier).toggle(year); + } + }, + ); }, - ); - }), + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + child: SizedBox( + width: double.infinity, + child: PrimaryButton( + label: 'APPLY FILTER', + onPressed: () => Navigator.pop(ctx), + ), + ), + ), + ), ], - ); - }, - ), - ), - ], + ), + ); + }, + ); + }, + ); + } + + /// Shows a modal bottom sheet checklist for statuses + void _showStatusMultiSelectModal( + BuildContext context, + List<_StatusOption> options, + Set selected, + ) { + final isDark = Theme.of(context).brightness == Brightness.dark; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return Consumer( + builder: (bottomSheetCtx, ref, _) { + final currentSelected = ref.watch(yearlyComparisonSelectedStatusesProvider); + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + ), + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceCard : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: isDark ? Colors.white24 : Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 12, 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Filter Student Statuses', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + 'Choose which student enrollment statuses to display in comparison chart', + style: TextStyle( + fontSize: 12, + color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), + child: Row( + children: [ + ActionChip( + label: const Text('All Statuses', style: TextStyle(fontSize: 12)), + backgroundColor: AppColors.primaryGreen.withValues(alpha: 0.1), + labelStyle: const TextStyle(color: AppColors.primaryGreen, fontWeight: FontWeight.w600), + side: BorderSide(color: AppColors.primaryGreen.withValues(alpha: 0.3)), + onPressed: () { + ref.read(yearlyComparisonSelectedStatusesProvider.notifier).setStatuses(options.map((o) => o.key).toSet()); + }, + ), + const Spacer(), + TextButton( + onPressed: () { + ref.read(yearlyComparisonSelectedStatusesProvider.notifier).clear(); + }, + child: const Text('Clear', style: TextStyle(fontSize: 12)), + ), + ], + ), + ), + const Divider(height: 1), + Flexible( + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: options.length, + separatorBuilder: (_, _) => Divider( + height: 1, + color: isDark ? AppColors.darkBorder.withValues(alpha: 0.5) : Colors.grey.shade100, + ), + itemBuilder: (context, idx) { + final opt = options[idx]; + final isChecked = currentSelected.contains(opt.key); + + return CheckboxListTile( + value: isChecked, + activeColor: AppColors.primaryGreen, + title: Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: opt.color, + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: 10), + Text( + opt.label, + style: TextStyle( + fontWeight: isChecked ? FontWeight.bold : FontWeight.w500, + fontSize: 14, + ), + ), + ], + ), + onChanged: (val) { + ref.read(yearlyComparisonSelectedStatusesProvider.notifier).toggle(opt.key); + }, + ); + }, + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + child: SizedBox( + width: double.infinity, + child: PrimaryButton( + label: 'APPLY FILTER', + onPressed: () => Navigator.pop(ctx), + ), + ), + ), + ), + ], + ), + ); + }, + ); + }, ); } diff --git a/frontend/lib/ui/screens/reports/widgets/transparency_board_section.dart b/frontend/lib/ui/screens/reports/widgets/transparency_board_section.dart index af29ada..e858611 100644 --- a/frontend/lib/ui/screens/reports/widgets/transparency_board_section.dart +++ b/frontend/lib/ui/screens/reports/widgets/transparency_board_section.dart @@ -63,6 +63,7 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon final ScrollController _enrollmentChartScrollController = ScrollController(); final ScrollController _dropoutChartScrollController = ScrollController(); final ScrollController _equity4PsChartScrollController = ScrollController(); + final ScrollController _tabBarScrollController = ScrollController(); bool _isExportingPdf = false; _TransparencyTab _activeTab = _TransparencyTab.all; bool _isEnrollmentInfoExpanded = false; @@ -74,6 +75,7 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon _enrollmentChartScrollController.dispose(); _dropoutChartScrollController.dispose(); _equity4PsChartScrollController.dispose(); + _tabBarScrollController.dispose(); super.dispose(); } @@ -259,10 +261,15 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon ), ], ), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisSize: MainAxisSize.min, + child: Scrollbar( + controller: _tabBarScrollController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _tabBarScrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(bottom: 6), + child: Row( + mainAxisSize: MainAxisSize.min, children: tabs.map((t) { final isSelected = _activeTab == t.$1; return Padding( @@ -315,6 +322,7 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon ), ); }).toList(), + ), ), ), ); @@ -715,10 +723,10 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.25), + color: Colors.white.withValues(alpha: 0.18), borderRadius: BorderRadius.circular(8), border: Border.all( - color: Colors.white.withValues(alpha: 0.35), + color: Colors.white.withValues(alpha: 0.45), ), ), child: Row( @@ -729,9 +737,9 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon const Text( 'Academic Year:', style: TextStyle( - color: Colors.white70, + color: Colors.white, fontSize: 12, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, ), ), const SizedBox(width: 6), @@ -741,11 +749,22 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon isDense: true, dropdownColor: isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite, - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.bold, - ), + selectedItemBuilder: (BuildContext context) { + return sortedYears.map((ay) { + final isActive = ay.status.toLowerCase() == 'active'; + return Align( + alignment: Alignment.centerLeft, + child: Text( + 'SY ${ay.yearRange}${isActive ? ' (Active)' : ''}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ); + }).toList(); + }, icon: const Icon(Icons.arrow_drop_down, color: Colors.white, size: 20), items: sortedYears.map((ay) { final isActive = ay.status.toLowerCase() == 'active'; @@ -754,7 +773,7 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon child: Text( 'SY ${ay.yearRange}${isActive ? ' (Active)' : ''}', style: TextStyle( - color: isDark ? Colors.white : Colors.black87, + color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary, fontSize: 12, fontWeight: isActive ? FontWeight.bold : FontWeight.w500, ), diff --git a/frontend/lib/ui/screens/settings/settings_screen.dart b/frontend/lib/ui/screens/settings/settings_screen.dart index 3ffe0d0..21c65d5 100644 --- a/frontend/lib/ui/screens/settings/settings_screen.dart +++ b/frontend/lib/ui/screens/settings/settings_screen.dart @@ -83,6 +83,7 @@ class _SettingsScreenState extends ConsumerState { final _extCtrl = TextEditingController(); final _phoneCtrl = TextEditingController(); final _emailCtrl = TextEditingController(); + final _gracePeriodCtrl = TextEditingController(); final _profileFormKey = GlobalKey(); @@ -156,6 +157,7 @@ class _SettingsScreenState extends ConsumerState { _extCtrl.dispose(); _phoneCtrl.dispose(); _emailCtrl.dispose(); + _gracePeriodCtrl.dispose(); super.dispose(); } @@ -1288,7 +1290,7 @@ class _SettingsScreenState extends ConsumerState { final sysSettingsAsync = ref.watch(systemSettingsProvider); final settingsMap = sysSettingsAsync.asData?.value ?? {}; final isAutoEnrollEnabled = - (settingsMap['auto_update_enrollment_from_sf'] ?? 'true') == 'true'; + (settingsMap['auto_update_enrollment_from_sf'] ?? 'false') == 'true'; final frequency = settingsMap['auto_update_enrollment_from_sf_frequency'] ?? 'immediate'; final timeVal = settingsMap['auto_update_enrollment_from_sf_time'] ?? '00:00'; final isDark = Theme.of(context).brightness == Brightness.dark; @@ -1425,6 +1427,142 @@ class _SettingsScreenState extends ConsumerState { ), ], ], + const Divider(height: 32), + // Auto-Archiving Grace Period + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.archive_outlined, + color: AppColors.primaryGreen, + size: 24, + ), + const SizedBox(width: AppSizes.p12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Automatic Archiving Grace Period', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + 'Enrolled students with no active enrollment past this number of days from the active school year start date are automatically marked Inactive and archived.', + style: TextStyle( + fontSize: 12, + color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade600, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSizes.p12), + Wrap( + spacing: 8, + runSpacing: 8, + children: ['15', '30', '45', '60', '90'].map((days) { + final currentGrace = settingsMap['enrollment_grace_period_days'] ?? '30'; + final isSelected = currentGrace == days; + return ChoiceChip( + label: Text('$days Days'), + selected: isSelected, + selectedColor: AppColors.primaryGreen.withValues(alpha: 0.18), + backgroundColor: isDark ? AppColors.darkSurfaceCard : Colors.grey.shade100, + side: BorderSide( + color: isSelected + ? AppColors.primaryGreen + : Theme.of(context).dividerColor.withValues(alpha: 0.3), + width: isSelected ? 1.5 : 1, + ), + labelStyle: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, + color: isSelected ? AppColors.primaryGreen : Theme.of(context).colorScheme.onSurface, + ), + onSelected: (_) { + _gracePeriodCtrl.text = days; + ref.read(systemSettingsProvider.notifier).updateSetting('enrollment_grace_period_days', days); + }, + ); + }).toList(), + ), + const SizedBox(height: AppSizes.p12), + Builder( + builder: (context) { + final currentGrace = settingsMap['enrollment_grace_period_days'] ?? '30'; + if (_gracePeriodCtrl.text.isEmpty) { + _gracePeriodCtrl.text = currentGrace; + } + return Row( + children: [ + SizedBox( + width: 140, + height: 40, + child: TextFormField( + controller: _gracePeriodCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + labelText: 'Custom Days', + labelStyle: const TextStyle(fontSize: 12), + suffixText: 'days', + suffixStyle: const TextStyle(fontSize: 12), + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + onFieldSubmitted: (val) { + final trimmed = val.trim(); + if (trimmed.isNotEmpty && int.tryParse(trimmed) != null && int.parse(trimmed) > 0) { + ref.read(systemSettingsProvider.notifier).updateSetting('enrollment_grace_period_days', trimmed); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Grace period updated to $trimmed days'), + duration: const Duration(seconds: 2), + ), + ); + } + }, + ), + ), + const SizedBox(width: 8), + SizedBox( + height: 40, + child: ElevatedButton( + onPressed: () { + final trimmed = _gracePeriodCtrl.text.trim(); + if (trimmed.isNotEmpty && int.tryParse(trimmed) != null && int.parse(trimmed) > 0) { + ref.read(systemSettingsProvider.notifier).updateSetting('enrollment_grace_period_days', trimmed); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Grace period updated to $trimmed days'), + duration: const Duration(seconds: 2), + ), + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + elevation: 0, + ), + child: const Text('Save', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold)), + ), + ), + ], + ); + }, + ), ], ), ); @@ -1892,12 +2030,20 @@ class _SettingsScreenState extends ConsumerState { width: double.infinity, padding: const EdgeInsets.all(AppSizes.p24), decoration: BoxDecoration( - color: isDark - ? AppColors.error.withValues(alpha: 0.08) - : AppColors.error.withValues(alpha: 0.04), + color: isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite, borderRadius: BorderRadius.circular(AppSizes.radiusLarge), + boxShadow: [ + BoxShadow( + color: isDark + ? Colors.black.withValues(alpha: 0.25) + : Colors.black.withValues(alpha: 0.03), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], border: Border.all( - color: AppColors.error.withValues(alpha: isDark ? 0.40 : 0.28), + color: AppColors.error.withValues(alpha: isDark ? 0.45 : 0.35), + width: 1.5, ), ), child: child, diff --git a/frontend/lib/ui/shared/buttons/primary_button.dart b/frontend/lib/ui/shared/buttons/primary_button.dart index 84c2aee..4e64731 100644 --- a/frontend/lib/ui/shared/buttons/primary_button.dart +++ b/frontend/lib/ui/shared/buttons/primary_button.dart @@ -7,6 +7,8 @@ class PrimaryButton extends StatelessWidget { final bool isLoading; final double? width; final double height; + final double fontSize; + final IconData? icon; const PrimaryButton({ super.key, @@ -15,17 +17,18 @@ class PrimaryButton extends StatelessWidget { this.isLoading = false, this.width = double.infinity, this.height = 50, + this.fontSize = 16, + this.icon, }); @override Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; final button = ElevatedButton( onPressed: isLoading ? null : onPressed, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF1C8248), - foregroundColor: isDark ? Colors.white : Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), ), @@ -37,14 +40,33 @@ class PrimaryButton extends StatelessWidget { size: 20, strokeWidth: 2.5, ) - : Text( - label, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - ), - ), + : (icon != null + ? Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: fontSize + 1, color: Colors.white), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + color: Colors.white, + ), + ), + ], + ) + : Text( + label, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + color: Colors.white, + ), + )), ); if (width != null) {