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:
ssbg04 committed 2026-09-19 02:26:49 +08:00
1 parent 2dc8ee4242
commit 69b4879ac2
28 files changed
+2981 -1206

No files matched your search

+9
View File
@@ -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');
+6
View File
@@ -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
}
}
+25 -2
View File
@@ -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) {
+13 -1
View File
@@ -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 {
+30 -3
View File
@@ -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 ')}` : '';
+1 -1
View File
@@ -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
+1
View File
@@ -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);
+125
View File
@@ -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
};