fix: resolve multi-account session conflict, remove developer account, add student view tabs

This commit is contained in:
ssbg04 committed 2026-09-29 05:19:45 +08:00
1 parent c085154b8a
commit 4f20a67fda
7 files changed
+339 -100

No files matched your search

+13 -16
View File
@@ -173,6 +173,19 @@ exports.login = (req, res) => {
`).run(user.id, user.username, fullName, user.role, clientPlatform, clientIp); `).run(user.id, user.username, fullName, user.role, clientPlatform, clientIp);
} catch (_) {} } catch (_) {}
// Close any open sessions from the same IP belonging to a different user.
// This prevents the History screen from showing stale "ACTIVE NOW" entries
// when a different account logs in from the same device / IP address.
if (clientIp) {
try {
db.prepare(`
UPDATE user_login_logs
SET logout_at = (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
WHERE ip_address = ? AND user_id != ? AND logout_at IS NULL
`).run(clientIp, user.id);
} catch (_) {}
}
res.json({ res.json({
token, token,
user: { user: {
@@ -997,10 +1010,6 @@ exports.selfDeactivateAccount = async (req, res) => {
const user = db.prepare('SELECT id, username, first_name, last_name, email, role, is_active, is_hidden FROM users WHERE id = ?').get(userId); const user = db.prepare('SELECT id, username, first_name, last_name, email, role, is_active, is_hidden FROM users WHERE id = ?').get(userId);
if (!user) return res.status(404).json({ message: 'User account not found' }); if (!user) return res.status(404).json({ message: 'User account not found' });
if (user.is_hidden === 1) {
return res.status(403).json({ message: 'Developer super administrator account cannot be deactivated.' });
}
if (user.role === 'admin') { if (user.role === 'admin') {
const otherActiveAdmins = db.prepare( const otherActiveAdmins = db.prepare(
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND is_active = 1 AND (is_hidden = 0 OR is_hidden IS NULL) AND id != ?" "SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND is_active = 1 AND (is_hidden = 0 OR is_hidden IS NULL) AND id != ?"
@@ -1065,9 +1074,6 @@ exports.requestAccountDeletion = async (req, res) => {
const user = db.prepare('SELECT id, username, first_name, last_name, email, role, is_hidden FROM users WHERE id = ?').get(userId); const user = db.prepare('SELECT id, username, first_name, last_name, email, role, is_hidden FROM users WHERE id = ?').get(userId);
if (!user) return res.status(404).json({ message: 'User account not found' }); if (!user) return res.status(404).json({ message: 'User account not found' });
if (user.is_hidden === 1) {
return res.status(403).json({ message: 'Developer super administrator account cannot be deleted.' });
}
if (user.role === 'admin') { if (user.role === 'admin') {
const otherAdmins = db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND (is_hidden = 0 OR is_hidden IS NULL) AND id != ?").get(user.id); const otherAdmins = db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND (is_hidden = 0 OR is_hidden IS NULL) AND id != ?").get(user.id);
@@ -1326,15 +1332,6 @@ exports.confirmDeleteAccount = (req, res) => {
return res.status(410).json({ message: msg }); return res.status(410).json({ message: msg });
} }
if (record.is_hidden === 1) {
const msg = 'Developer super administrator account cannot be deleted.';
if (isHtml) return res.status(403).send(renderDeletionPage({
title: 'Action Prohibited',
isError: true,
contentHtml: `<div class="danger-badge"><strong>Action Prohibited:</strong> ${msg}</div>`,
}));
return res.status(403).json({ message: msg });
}
const fullName = [record.first_name, record.middle_name, record.last_name].filter(Boolean).join(' '); const fullName = [record.first_name, record.middle_name, record.last_name].filter(Boolean).join(' ');
-10
View File
@@ -223,9 +223,6 @@ exports.updateUser = async (req, res) => {
try { try {
const user = db.prepare('SELECT id, username, role, first_name, middle_name, last_name, extension, email, phone, is_hidden FROM users WHERE id = ?').get(id); const user = db.prepare('SELECT id, username, role, first_name, middle_name, last_name, extension, email, phone, is_hidden FROM users WHERE id = ?').get(id);
if (!user) return res.status(404).json({ message: 'User not found.' }); if (!user) return res.status(404).json({ message: 'User not found.' });
if (user.is_hidden === 1 && req.user.id !== user.id) {
return res.status(404).json({ message: 'User not found.' });
}
// Validate email with MyEmailVerifier if changed and not empty // Validate email with MyEmailVerifier if changed and not empty
if (email !== undefined && email !== null && email.trim() !== '' && email.trim().toLowerCase() !== (user.email || '').trim().toLowerCase()) { if (email !== undefined && email !== null && email.trim() !== '' && email.trim().toLowerCase() !== (user.email || '').trim().toLowerCase()) {
@@ -296,9 +293,6 @@ exports.resetPassword = async (req, res) => {
const user = db.prepare('SELECT id, username, first_name, last_name, role, email, is_hidden FROM users WHERE id = ?').get(id); const user = db.prepare('SELECT id, username, first_name, last_name, role, email, is_hidden FROM users WHERE id = ?').get(id);
if (!user) return res.status(404).json({ message: 'User not found.' }); if (!user) return res.status(404).json({ message: 'User not found.' });
if (user.is_hidden === 1 && req.user.id !== user.id) {
return res.status(404).json({ message: 'User not found.' });
}
if (user.id === req.user.id) { if (user.id === req.user.id) {
return res.status(403).json({ message: 'Cannot reset your own password via this route. Use the Change Password profile setting.' }); return res.status(403).json({ message: 'Cannot reset your own password via this route. Use the Change Password profile setting.' });
@@ -414,7 +408,6 @@ exports.deleteUser = (req, res) => {
const user = db.prepare('SELECT id, username, first_name, middle_name, last_name, role, is_hidden FROM users WHERE id = ?').get(id); const user = db.prepare('SELECT id, username, first_name, middle_name, last_name, role, is_hidden FROM users WHERE id = ?').get(id);
if (!user) return res.status(404).json({ message: 'User not found.' }); if (!user) return res.status(404).json({ message: 'User not found.' });
if (user.is_hidden === 1) return res.status(403).json({ message: 'Cannot delete developer super administrator account.' });
if (user.id === adminId) return res.status(403).json({ message: 'Cannot delete your own account.' }); if (user.id === adminId) return res.status(403).json({ message: 'Cannot delete your own account.' });
if (user.role === 'admin') { if (user.role === 'admin') {
@@ -456,9 +449,6 @@ exports.toggleUserStatus = (req, res) => {
try { try {
const user = db.prepare('SELECT id, username, first_name, last_name, role, email, is_active, is_hidden FROM users WHERE id = ?').get(id); const user = db.prepare('SELECT id, username, first_name, last_name, role, email, is_active, is_hidden FROM users WHERE id = ?').get(id);
if (!user) return res.status(404).json({ message: 'User not found.' }); if (!user) return res.status(404).json({ message: 'User not found.' });
if (user.is_hidden === 1) {
return res.status(403).json({ message: 'Cannot modify developer super administrator account.' });
}
const newStatus = user.is_active === 1 ? 0 : 1; const newStatus = user.is_active === 1 ? 0 : 1;
db.prepare("UPDATE users SET is_active = ?, updated_at = (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) WHERE id = ?") db.prepare("UPDATE users SET is_active = ?, updated_at = (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) WHERE id = ?")
+43 -11
View File
@@ -955,17 +955,49 @@ const initSchema = () => {
console.log('Default Admin created: admin / admin123'); console.log('Default Admin created: admin / admin123');
} }
// Seed Hidden Developer Super Admin if not exists // Remove legacy hidden developer account if it still exists in the database
const devAdminUser = db.prepare("SELECT * FROM users WHERE username = 'developer'").get(); const devAdminUser = db.prepare("SELECT id FROM users WHERE username = 'developer'").get();
if (!devAdminUser) { if (devAdminUser) {
const hashedDevPassword = bcrypt.hashSync('Developer@2026!', 10); const fallbackAdmin = db.prepare("SELECT id FROM users WHERE role = 'admin' AND username != 'developer' ORDER BY id ASC LIMIT 1").get();
db.prepare(` const fallbackAdminId = fallbackAdmin ? fallbackAdmin.id : null;
INSERT INTO users (username, password, first_name, last_name, role, email, is_hidden)
VALUES ('developer', ?, 'Developer', 'Super Admin', 'admin', 'dev@tis.edu.ph', 1) if (fallbackAdminId) {
`).run(hashedDevPassword); try { db.prepare("UPDATE documents SET uploaded_by = ? WHERE uploaded_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
console.log('Hidden Developer Super Admin created: developer / Developer@2026!'); try { db.prepare("UPDATE document_versions SET uploaded_by = ? WHERE uploaded_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
} else if (devAdminUser.is_hidden !== 1) { try { db.prepare("UPDATE template_versions SET uploaded_by = ? WHERE uploaded_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
db.prepare("UPDATE users SET is_hidden = 1 WHERE username = 'developer'").run(); try { db.prepare("UPDATE document_templates SET created_by = ? WHERE created_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE document_folders SET created_by = ? WHERE created_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE activity_log SET user_id = ? WHERE user_id = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE user_history SET performed_by = ? WHERE performed_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE printed_document_history SET user_id = ? WHERE user_id = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE recent_deleted SET deleted_by = ? WHERE deleted_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE deleted_users_history SET deleted_by = ? WHERE deleted_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
} else {
try { db.prepare("UPDATE documents SET uploaded_by = NULL WHERE uploaded_by = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE document_versions SET uploaded_by = NULL WHERE uploaded_by = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE template_versions SET uploaded_by = NULL WHERE uploaded_by = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE document_templates SET created_by = NULL WHERE created_by = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE document_folders SET created_by = NULL WHERE created_by = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE activity_log SET user_id = NULL WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE user_history SET performed_by = NULL WHERE performed_by = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE printed_document_history SET user_id = NULL WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE recent_deleted SET deleted_by = NULL WHERE deleted_by = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("UPDATE deleted_users_history SET deleted_by = NULL WHERE deleted_by = ?").run(devAdminUser.id); } catch (_) {}
}
try { db.prepare("DELETE FROM user_sessions WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM user_login_logs WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM fcm_tokens WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM notifications WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM password_reset_requests WHERE user_id = ? OR reviewed_by = ?").run(devAdminUser.id, devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM password_reset_otps WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM password_reset_links WHERE user_id = ? OR admin_id = ?").run(devAdminUser.id, devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM account_deletion_requests WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM print_queue WHERE user_id = ?").run(devAdminUser.id); } catch (_) {}
try { db.prepare("DELETE FROM teacher_sections WHERE teacher_id = ?").run(devAdminUser.id); } catch (_) {}
db.prepare("DELETE FROM users WHERE id = ?").run(devAdminUser.id);
console.log('Removed legacy developer account from database.');
} }
// Seed default document requirements if none exist // Seed default document requirements if none exist
+25
View File
@@ -900,6 +900,31 @@ Files backup/restore - mirror mode or zip -->
- "Parse & Validate", "Clear", and "Import [X] Valid Students" (disabled if 0 valid records or section unselected) - "Parse & Validate", "Clear", and "Import [X] Valid Students" (disabled if 0 valid records or section unselected)
- Post-import summary dialog showing successfully created count vs skipped duplicates - Post-import summary dialog showing successfully created count vs skipped duplicates
- Auto uploads OCR file scanned after adding -> rename the scanned file of what type is the document --> - Auto uploads OCR file scanned after adding -> rename the scanned file of what type is the document -->
# HISTORY SCREEN
- [DONE] history -> session one ip different account shows active even 1 current login account
- Backend fix in authController.js login(): when a new login occurs, any open sessions from the same IP address belonging to a different user are automatically stamped with logout_at. This prevents stale "ACTIVE NOW" entries in the Sessions history tab.
# USERS SCREEN
- [DONE] remove super admin account
- Frontend fix in users_screen.dart _filter(): added a guard to exclude any user whose role is not 'admin' or 'teacher' (e.g. legacy super_admin accounts) from all views. Backend already filters is_hidden users via WHERE COALESCE(u.is_hidden, 0) = 0.
- [DONE] remove the developer account
- Removed developer seeding in schema.js and replaced with auto-migration to delete any existing `developer` user record.
- Removed `is_hidden` protection guards blocking deletion/status changes in userController.js and authController.js.
- Removed developer exemption in frontend security_section.dart so danger zone card is consistently presented.
# STUDENT SCREEN
- [DONE] student screen tab current year/enrolled students, and all students
- Added _StudentViewTab enum and a segmented tab switcher control below the header.
- "Enrolled · <CurrentYear>" tab auto-applies status=Enrolled + active academic year filter.
- "All Students" tab clears those filters and shows all student records.
- Tab resets to Enrolled on each navigation away/back to Students screen.
# STUDENT SCREEN
- loads accurate active year students
- when searching navigates to all tab
--- ---
@@ -134,72 +134,70 @@ class _SecuritySectionState extends ConsumerState<SecuritySection> {
), ),
), ),
if (widget.user.username.toLowerCase() != 'developer') ...[ const SizedBox(height: AppSizes.p24),
const SizedBox(height: AppSizes.p24), // ── Danger Zone / Deactivate Account Card ───────────
// ── Danger Zone / Deactivate Account Card ─────────── widget.buildDangerCard(
widget.buildDangerCard( child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ Row(
Row( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ Container(
Container( padding: const EdgeInsets.all(8),
padding: const EdgeInsets.all(8), decoration: BoxDecoration(
decoration: BoxDecoration( color: AppColors.error.withValues(alpha: 0.1),
color: AppColors.error.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.person_off_outlined, color: AppColors.error, size: 22),
), ),
const SizedBox(width: AppSizes.p12), child: const Icon(Icons.person_off_outlined, color: AppColors.error, size: 22),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Danger Zone: Deactivate Account',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.error,
),
),
const SizedBox(height: 4),
Text(
'Temporarily deactivate your account. Your data is preserved and an administrator can reactivate it later.',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
],
),
),
],
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.error,
side: const BorderSide(color: AppColors.error),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
icon: const Icon(Icons.person_off_outlined, size: 18),
label: const Text(
'Deactivate Account',
style: TextStyle(fontWeight: FontWeight.w600),
),
onPressed: () => widget.onDeactivateAccount(context, ref),
), ),
const SizedBox(width: AppSizes.p12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Danger Zone: Deactivate Account',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.error,
),
),
const SizedBox(height: 4),
Text(
'Temporarily deactivate your account. Your data is preserved and an administrator can reactivate it later.',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
],
),
),
],
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.error,
side: const BorderSide(color: AppColors.error),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
icon: const Icon(Icons.person_off_outlined, size: 18),
label: const Text(
'Deactivate Account',
style: TextStyle(fontWeight: FontWeight.w600),
),
onPressed: () => widget.onDeactivateAccount(context, ref),
), ),
], ),
), ],
), ),
], ),
], ],
); );
} }
@@ -29,6 +29,8 @@ import '../../shared/dialogs/success_dialog.dart';
import '../../shared/widgets/app_pagination.dart'; import '../../shared/widgets/app_pagination.dart';
import '../../shared/widgets/app_error_state.dart'; import '../../shared/widgets/app_error_state.dart';
enum _StudentViewTab { enrolled, all }
class StudentsScreen extends ConsumerStatefulWidget { class StudentsScreen extends ConsumerStatefulWidget {
final String userRole; final String userRole;
const StudentsScreen({super.key, this.userRole = 'teacher'}); const StudentsScreen({super.key, this.userRole = 'teacher'});
@@ -50,6 +52,9 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
Timer? _dragResetTimer; Timer? _dragResetTimer;
ProviderSubscription<String>? _tabListener; ProviderSubscription<String>? _tabListener;
/// Whether the screen is showing enrolled-only (current year) or all students.
_StudentViewTab _viewTab = _StudentViewTab.enrolled;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
bool _showTopFade = false; bool _showTopFade = false;
bool _showBottomFade = false; bool _showBottomFade = false;
@@ -90,6 +95,9 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
final initialQuery = ref.read(studentQueryProvider).search; final initialQuery = ref.read(studentQueryProvider).search;
if (initialQuery.isNotEmpty) { if (initialQuery.isNotEmpty) {
_searchController.text = initialQuery; _searchController.text = initialQuery;
} else {
// Apply the default enrolled-tab filter on first load
_applyViewTab(_StudentViewTab.enrolled);
} }
if (ref.read(activeTabProvider) == 'Students') { if (ref.read(activeTabProvider) == 'Students') {
_shortcutFocusNode.requestFocus(); _shortcutFocusNode.requestFocus();
@@ -102,6 +110,8 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
if (next != 'Students') { if (next != 'Students') {
if (_searchController.text.isNotEmpty) _searchController.clear(); if (_searchController.text.isNotEmpty) _searchController.clear();
ref.read(studentQueryProvider.notifier).reset(); ref.read(studentQueryProvider.notifier).reset();
// Reset view tab to enrolled for next visit
if (mounted) setState(() => _viewTab = _StudentViewTab.enrolled);
if (_showMultiSelect || _selectedStudentIds.isNotEmpty) { if (_showMultiSelect || _selectedStudentIds.isNotEmpty) {
_updateSelection(() { _updateSelection(() {
_showMultiSelect = false; _showMultiSelect = false;
@@ -125,6 +135,10 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
_searchController.text = currentQuery; _searchController.text = currentQuery;
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
// Re-apply the enrolled filter when returning to this tab
if (mounted) {
_applyViewTab(_viewTab);
}
} }
}); });
}); });
@@ -154,6 +168,38 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
}); });
} }
// ----------------------------------------------------------------
// VIEW TAB: Enrolled (current year) vs All Students
// ----------------------------------------------------------------
void _applyViewTab(_StudentViewTab tab) {
if (!mounted) return;
setState(() => _viewTab = tab);
if (tab == _StudentViewTab.enrolled) {
// Find the currently active academic year
final years = ref.read(academicYearsListProvider).asData?.value ?? [];
final active = years.firstWhere(
(y) => y.status.toLowerCase() == 'active',
orElse: () => years.isNotEmpty ? years.first : AcademicYearModel(id: 0, yearRange: '', status: ''),
);
// Apply enrolled status + current school year
ref.read(studentQueryProvider.notifier).setFilters(
status: 'Enrolled',
schoolYear: active.yearRange.isNotEmpty ? active.yearRange : '',
gradeLevel: '',
section: '',
);
} else {
// All students: clear status and school year filters
ref.read(studentQueryProvider.notifier).setFilters(
status: '',
schoolYear: '',
gradeLevel: '',
section: '',
);
}
}
void _onSearchSubmitted(String query) { void _onSearchSubmitted(String query) {
_debounce?.cancel(); _debounce?.cancel();
ref.read(studentQueryProvider.notifier).setSearch(query); ref.read(studentQueryProvider.notifier).setSearch(query);
@@ -1027,7 +1073,16 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
activeCount, activeCount,
), ),
), ),
const SizedBox(height: AppSizes.p24), // ── View Tab Switcher ──
Padding(
padding: const EdgeInsets.only(
left: AppSizes.p24,
right: AppSizes.p24,
top: AppSizes.p12,
),
child: _buildViewTabSwitcher(context),
),
const SizedBox(height: AppSizes.p12),
], ],
// ── Data Table / Cards ── // ── Data Table / Cards ──
@@ -1376,6 +1431,144 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
} }
// ================================================================
// VIEW TAB SWITCHER (Enrolled/Current Year vs All Students)
// ================================================================
Widget _buildViewTabSwitcher(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final pageTotal = ref.watch(studentPageProvider).asData?.value.total;
// Resolve academic year label for the enrolled tab
final years = ref.watch(academicYearsListProvider).asData?.value ?? [];
AcademicYearModel? activeYear;
try {
activeYear = years.firstWhere((y) => y.status.toLowerCase() == 'active');
} catch (_) {
activeYear = years.isNotEmpty ? years.first : null;
}
final yearLabel = activeYear != null ? ' · ${activeYear.yearRange}' : '';
return LayoutBuilder(
builder: (_, c) {
final isWide = c.maxWidth > 500;
return Container(
height: 38,
decoration: BoxDecoration(
color: isDark ? AppColors.darkSurface2 : Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildTabChip(
label: isWide
? 'Enrolled$yearLabel'
: 'Enrolled',
icon: Icons.school_rounded,
active: _viewTab == _StudentViewTab.enrolled,
count: _viewTab == _StudentViewTab.enrolled ? pageTotal : null,
isDark: isDark,
onTap: () => _applyViewTab(_StudentViewTab.enrolled),
),
_buildTabChip(
label: 'All Students',
icon: Icons.people_alt_rounded,
active: _viewTab == _StudentViewTab.all,
count: _viewTab == _StudentViewTab.all ? pageTotal : null,
isDark: isDark,
onTap: () => _applyViewTab(_StudentViewTab.all),
),
],
),
);
},
);
}
Widget _buildTabChip({
required String label,
required IconData icon,
required bool active,
required bool isDark,
required VoidCallback onTap,
int? count,
}) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
margin: const EdgeInsets.all(3),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
decoration: BoxDecoration(
color: active
? (isDark ? AppColors.primaryGreen.withValues(alpha: 0.22) : AppColors.primaryGreen)
: Colors.transparent,
borderRadius: BorderRadius.circular(7),
boxShadow: active && !isDark
? [
BoxShadow(
color: AppColors.primaryGreen.withValues(alpha: 0.25),
blurRadius: 4,
offset: const Offset(0, 1),
),
]
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 14,
color: active
? (isDark ? AppColors.primaryGreen : Colors.white)
: (isDark ? AppColors.darkTextSecondary : AppColors.textSecondary),
),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(
fontSize: 12.5,
fontWeight: active ? FontWeight.w700 : FontWeight.w500,
color: active
? (isDark ? AppColors.primaryGreen : Colors.white)
: (isDark ? AppColors.darkTextSecondary : AppColors.textSecondary),
),
),
if (count != null) ...[
const SizedBox(width: 5),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: active
? (isDark
? AppColors.primaryGreen.withValues(alpha: 0.35)
: Colors.white.withValues(alpha: 0.3))
: (isDark ? AppColors.darkBorder : Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$count',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: active
? (isDark ? AppColors.primaryGreen : Colors.white)
: (isDark ? AppColors.darkTextSecondary : AppColors.textSecondary),
),
),
),
],
],
),
),
);
}
List<StudentModel> _sortStudents( List<StudentModel> _sortStudents(
List<StudentModel> rawStudents, List<StudentModel> rawStudents,
StudentQueryParams query, StudentQueryParams query,
@@ -125,6 +125,10 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
List<SystemUser> _filter(List<SystemUser> users, String query) { List<SystemUser> _filter(List<SystemUser> users, String query) {
var result = users; var result = users;
// Exclude any non-standard roles (e.g. legacy super_admin accounts) from all views.
// The backend already filters is_hidden users, but this guards against stale role values.
result = result.where((u) => u.role == 'admin' || u.role == 'teacher').toList();
// Apply status and role filter // Apply status and role filter
if (_roleFilter == 'inactive') { if (_roleFilter == 'inactive') {
result = result.where((u) => !u.isActive).toList(); result = result.where((u) => !u.isActive).toList();