fix: resolve multi-account session conflict, remove developer account, add student view tabs
This commit is contained in:
1 parent
c085154b8a
commit
4f20a67fda
7 files changed
+339
-100
No files matched your search
@@ -173,6 +173,19 @@ exports.login = (req, res) => {
|
||||
`).run(user.id, user.username, fullName, user.role, clientPlatform, clientIp);
|
||||
} 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({
|
||||
token,
|
||||
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);
|
||||
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') {
|
||||
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 != ?"
|
||||
@@ -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);
|
||||
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') {
|
||||
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 });
|
||||
}
|
||||
|
||||
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(' ');
|
||||
|
||||
|
||||
@@ -223,9 +223,6 @@ exports.updateUser = async (req, res) => {
|
||||
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);
|
||||
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
|
||||
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);
|
||||
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) {
|
||||
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);
|
||||
|
||||
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.role === 'admin') {
|
||||
@@ -456,9 +449,6 @@ exports.toggleUserStatus = (req, res) => {
|
||||
try {
|
||||
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.is_hidden === 1) {
|
||||
return res.status(403).json({ message: 'Cannot modify developer super administrator account.' });
|
||||
}
|
||||
|
||||
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 = ?")
|
||||
|
||||
@@ -955,17 +955,49 @@ const initSchema = () => {
|
||||
console.log('Default Admin created: admin / admin123');
|
||||
}
|
||||
|
||||
// Seed Hidden Developer Super Admin if not exists
|
||||
const devAdminUser = db.prepare("SELECT * FROM users WHERE username = 'developer'").get();
|
||||
if (!devAdminUser) {
|
||||
const hashedDevPassword = bcrypt.hashSync('Developer@2026!', 10);
|
||||
db.prepare(`
|
||||
INSERT INTO users (username, password, first_name, last_name, role, email, is_hidden)
|
||||
VALUES ('developer', ?, 'Developer', 'Super Admin', 'admin', 'dev@tis.edu.ph', 1)
|
||||
`).run(hashedDevPassword);
|
||||
console.log('Hidden Developer Super Admin created: developer / Developer@2026!');
|
||||
} else if (devAdminUser.is_hidden !== 1) {
|
||||
db.prepare("UPDATE users SET is_hidden = 1 WHERE username = 'developer'").run();
|
||||
// Remove legacy hidden developer account if it still exists in the database
|
||||
const devAdminUser = db.prepare("SELECT id FROM users WHERE username = 'developer'").get();
|
||||
if (devAdminUser) {
|
||||
const fallbackAdmin = db.prepare("SELECT id FROM users WHERE role = 'admin' AND username != 'developer' ORDER BY id ASC LIMIT 1").get();
|
||||
const fallbackAdminId = fallbackAdmin ? fallbackAdmin.id : null;
|
||||
|
||||
if (fallbackAdminId) {
|
||||
try { db.prepare("UPDATE documents SET uploaded_by = ? WHERE uploaded_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
|
||||
try { db.prepare("UPDATE document_versions SET uploaded_by = ? WHERE uploaded_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
|
||||
try { db.prepare("UPDATE template_versions SET uploaded_by = ? WHERE uploaded_by = ?").run(fallbackAdminId, devAdminUser.id); } catch (_) {}
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
- 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 -->
|
||||
|
||||
# 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),
|
||||
// ── Danger Zone / Deactivate Account Card ───────────
|
||||
widget.buildDangerCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.person_off_outlined, color: AppColors.error, size: 22),
|
||||
const SizedBox(height: AppSizes.p24),
|
||||
// ── Danger Zone / Deactivate Account Card ───────────
|
||||
widget.buildDangerCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
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),
|
||||
child: const Icon(Icons.person_off_outlined, color: AppColors.error, size: 22),
|
||||
),
|
||||
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_error_state.dart';
|
||||
|
||||
enum _StudentViewTab { enrolled, all }
|
||||
|
||||
class StudentsScreen extends ConsumerStatefulWidget {
|
||||
final String userRole;
|
||||
const StudentsScreen({super.key, this.userRole = 'teacher'});
|
||||
@@ -50,6 +52,9 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
Timer? _dragResetTimer;
|
||||
ProviderSubscription<String>? _tabListener;
|
||||
|
||||
/// Whether the screen is showing enrolled-only (current year) or all students.
|
||||
_StudentViewTab _viewTab = _StudentViewTab.enrolled;
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _showTopFade = false;
|
||||
bool _showBottomFade = false;
|
||||
@@ -90,6 +95,9 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
final initialQuery = ref.read(studentQueryProvider).search;
|
||||
if (initialQuery.isNotEmpty) {
|
||||
_searchController.text = initialQuery;
|
||||
} else {
|
||||
// Apply the default enrolled-tab filter on first load
|
||||
_applyViewTab(_StudentViewTab.enrolled);
|
||||
}
|
||||
if (ref.read(activeTabProvider) == 'Students') {
|
||||
_shortcutFocusNode.requestFocus();
|
||||
@@ -102,6 +110,8 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
if (next != 'Students') {
|
||||
if (_searchController.text.isNotEmpty) _searchController.clear();
|
||||
ref.read(studentQueryProvider.notifier).reset();
|
||||
// Reset view tab to enrolled for next visit
|
||||
if (mounted) setState(() => _viewTab = _StudentViewTab.enrolled);
|
||||
if (_showMultiSelect || _selectedStudentIds.isNotEmpty) {
|
||||
_updateSelection(() {
|
||||
_showMultiSelect = false;
|
||||
@@ -125,6 +135,10 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
_searchController.text = currentQuery;
|
||||
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) {
|
||||
_debounce?.cancel();
|
||||
ref.read(studentQueryProvider.notifier).setSearch(query);
|
||||
@@ -1027,7 +1073,16 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
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 ──
|
||||
@@ -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> rawStudents,
|
||||
StudentQueryParams query,
|
||||
|
||||
@@ -125,6 +125,10 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
||||
|
||||
List<SystemUser> _filter(List<SystemUser> users, String query) {
|
||||
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
|
||||
if (_roleFilter == 'inactive') {
|
||||
result = result.where((u) => !u.isActive).toList();
|
||||
|
||||
Reference in new issue
Block a user