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
This commit is contained in:
1 parent
2dc8ee4242
commit
69b4879ac2
28 files changed
+2981
-1206
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ')}` : '';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
Reference in new issue
Block a user