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);
|
`).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(' ');
|
||||||
|
|
||||||
|
|||||||
@@ -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 = ?")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user