From 03eda282bf8bdf810838bc6a55aefc1119297967 Mon Sep 17 00:00:00 2001 From: ssbg04 Date: Sat, 1 Aug 2026 00:25:03 +0800 Subject: [PATCH] feat: add OCR and Excel SF9/SF10 enrollment auto-update with teacher validation and semester deduplication --- backend/index.js | 2 + backend/src/controllers/documentController.js | 14 + backend/src/controllers/settingsController.js | 71 +++++ backend/src/controllers/studentController.js | 73 ++++++ backend/src/models/schema.js | 13 + backend/src/routes/settings.js | 9 + backend/src/routes/students.js | 1 + backend/src/services/ocrParser.js | 210 ++++++++++++++- backend/src/services/sfEnrollmentService.js | 245 ++++++++++++++++++ .../repositories/settings_repository.dart | 48 ++++ .../providers/system_settings_provider.dart | 37 +++ .../widgets/student_profile_modal.dart | 152 ++++++++--- .../ui/screens/settings/settings_screen.dart | 62 +++++ 13 files changed, 895 insertions(+), 42 deletions(-) create mode 100644 backend/src/controllers/settingsController.js create mode 100644 backend/src/routes/settings.js create mode 100644 backend/src/services/sfEnrollmentService.js create mode 100644 frontend/lib/domain/repositories/settings_repository.dart create mode 100644 frontend/lib/ui/providers/system_settings_provider.dart diff --git a/backend/index.js b/backend/index.js index ae3ed1d..d011cd2 100644 --- a/backend/index.js +++ b/backend/index.js @@ -27,6 +27,7 @@ const ocrRoutes = require('./src/routes/ocr.routes.js'); const notificationRoutes = require('./src/routes/notifications'); const backupRoutes = require('./src/routes/backup'); const serverRoutes = require('./src/routes/server.routes'); +const settingsRoutes = require('./src/routes/settings'); const app = express(); const PORT = process.env.PORT || 18484; @@ -55,6 +56,7 @@ app.use('/api/ocr', ocrRoutes); app.use('/api/notifications', notificationRoutes); app.use('/api/backup', backupRoutes); app.use('/api/server', serverRoutes); +app.use('/api/settings', settingsRoutes); app.get(['/', '/api'], (req, res) => { res.set('X-TIS-RMS', 'true'); diff --git a/backend/src/controllers/documentController.js b/backend/src/controllers/documentController.js index 3a732b4..1e0307c 100644 --- a/backend/src/controllers/documentController.js +++ b/backend/src/controllers/documentController.js @@ -160,6 +160,20 @@ exports.uploadDocument = (req, res) => { createNotification(null, 'Document Uploaded', `Document "${file.originalname}" (${documentType}) has been uploaded for ${studentName}.`, 'document', 'document', result.lastInsertRowid); + // Check auto-enrollment update if document is SF10 or SF9 + try { + const sfEnrollmentService = require('../services/sfEnrollmentService'); + sfEnrollmentService.autoEnrollFromSF({ + studentId, + file: { path: file.path, originalname: file.originalname, mimetype: file.mimetype }, + documentType, + userId: req.user?.id, + manual: false + }).catch(e => console.error('[uploadDocument] autoEnrollFromSF async error:', e.message)); + } catch (sfErr) { + console.error('[uploadDocument] autoEnrollFromSF trigger error:', sfErr.message); + } + res.status(201).json({ id: result.lastInsertRowid, message: 'Document uploaded successfully' }); } catch (error) { console.error('Upload Error:', error); diff --git a/backend/src/controllers/settingsController.js b/backend/src/controllers/settingsController.js new file mode 100644 index 0000000..c83632b --- /dev/null +++ b/backend/src/controllers/settingsController.js @@ -0,0 +1,71 @@ +const db = require('../config/db'); + +exports.getSettings = (req, res) => { + try { + const rows = db.prepare('SELECT key, value FROM system_settings').all(); + const settings = { + auto_update_enrollment_from_sf: 'true' + }; + for (const row of rows) { + settings[row.key] = row.value; + } + res.json({ + success: true, + data: settings + }); + } catch (error) { + console.error('getSettings error:', error); + res.status(500).json({ + success: false, + message: 'Failed to retrieve system settings', + error: error.message + }); + } +}; + +exports.updateSettings = (req, res) => { + try { + const updates = req.body.settings || req.body; + if (!updates || typeof updates !== 'object') { + return res.status(400).json({ + success: false, + message: 'Invalid settings payload' + }); + } + + const stmt = db.prepare(` + INSERT INTO system_settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = CURRENT_TIMESTAMP + `); + + const insertOrUpdate = db.transaction((settingsObj) => { + for (const [key, value] of Object.entries(settingsObj)) { + stmt.run(key, String(value)); + } + }); + + insertOrUpdate(updates); + + const rows = db.prepare('SELECT key, value FROM system_settings').all(); + const updatedSettings = {}; + for (const row of rows) { + updatedSettings[row.key] = row.value; + } + + res.json({ + success: true, + message: 'System settings updated successfully', + data: updatedSettings + }); + } catch (error) { + console.error('updateSettings error:', error); + res.status(500).json({ + success: false, + message: 'Failed to update system settings', + error: error.message + }); + } +}; diff --git a/backend/src/controllers/studentController.js b/backend/src/controllers/studentController.js index 1070855..b60319a 100644 --- a/backend/src/controllers/studentController.js +++ b/backend/src/controllers/studentController.js @@ -743,3 +743,76 @@ exports.deleteEnrollment = (req, res) => { res.status(500).json({ message: 'Failed to delete enrollment', error: error.message }); } }; + +// ============================================================ +// POST /api/students/:id/ocr-enrollment +// ============================================================ +exports.scanEnrollmentFromSF = async (req, res) => { + try { + const studentId = req.params.id; + const docs = db.prepare(` + SELECT * FROM documents + WHERE student_id = ? AND deleted_at IS NULL + AND ( + document_type LIKE '%SF10%' OR document_type LIKE '%SF9%' + OR document_type LIKE '%Form 10%' OR document_type LIKE '%Form 9%' + OR document_type LIKE '%Report Card%' OR document_type LIKE '%Permanent Record%' + OR file_name LIKE '%sf10%' OR file_name LIKE '%sf9%' + ) + ORDER BY id DESC + `).all(studentId); + + if (!docs || docs.length === 0) { + return res.status(404).json({ + success: false, + message: 'No SF10 or SF9 document found for this student. Please upload an SF10 or SF9 document first.' + }); + } + + const sfEnrollmentService = require('../services/sfEnrollmentService'); + let lastResult = null; + let successCount = 0; + let results = []; + let aggregatedRecords = []; + + for (const doc of docs) { + const resData = await sfEnrollmentService.autoEnrollFromSF({ + studentId, + file: { path: doc.file_path, originalname: doc.file_name }, + documentType: doc.document_type, + userId: req.user?.id, + manual: true + }); + results.push(resData); + if (resData.success) { + successCount++; + lastResult = resData; + if (Array.isArray(resData.allRecords)) { + aggregatedRecords.push(...resData.allRecords); + } + } + } + + if (successCount === 0) { + return res.status(400).json({ + success: false, + message: lastResult?.message || 'Failed to extract Academic Year, Grade Level, or Section from the scanned SF10/SF9 documents.', + details: results + }); + } + + if (aggregatedRecords.length > 0) { + lastResult.allRecords = aggregatedRecords; + } + + return res.json({ + success: true, + message: `Successfully scanned SF10/SF9 and processed ${aggregatedRecords.length || 1} enrollment record(s).`, + data: lastResult + }); + } catch (error) { + console.error('scanEnrollmentFromSF error:', error); + res.status(500).json({ success: false, message: 'Failed to scan SF10/SF9 for enrollment', error: error.message }); + } +}; + diff --git a/backend/src/models/schema.js b/backend/src/models/schema.js index 153de17..41d1ebb 100644 --- a/backend/src/models/schema.js +++ b/backend/src/models/schema.js @@ -652,6 +652,19 @@ const initSchema = () => { ) `).run(); + // 11. System Settings Table + db.prepare(` + CREATE TABLE IF NOT EXISTS system_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `).run(); + db.prepare(` + INSERT OR IGNORE INTO system_settings (key, value) + VALUES ('auto_update_enrollment_from_sf', 'true') + `).run(); + // Seed Admin if not exists const adminUser = db.prepare("SELECT * FROM users WHERE username = 'admin' OR role = 'admin'").get(); if (!adminUser) { diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js new file mode 100644 index 0000000..28b466b --- /dev/null +++ b/backend/src/routes/settings.js @@ -0,0 +1,9 @@ +const express = require('express'); +const router = express.Router(); +const settingsController = require('../controllers/settingsController'); +const { authenticateToken, authorizeRoles } = require('../middleware/auth'); + +router.get('/', authenticateToken, authorizeRoles('admin'), settingsController.getSettings); +router.put('/', authenticateToken, authorizeRoles('admin'), settingsController.updateSettings); + +module.exports = router; diff --git a/backend/src/routes/students.js b/backend/src/routes/students.js index bc892f2..e3a56eb 100644 --- a/backend/src/routes/students.js +++ b/backend/src/routes/students.js @@ -16,6 +16,7 @@ router.put('/:id', authenticateToken, authorizeRoles('admin'), studentControlle router.delete('/:id', authenticateToken, authorizeRoles('admin'), studentController.deleteStudent); router.post('/:id/enrollments', authenticateToken, authorizeRoles('admin'), studentController.addEnrollment); +router.post('/:id/ocr-enrollment', authenticateToken, authorizeRoles('admin'), studentController.scanEnrollmentFromSF); router.put('/enrollments/:enrollmentId', authenticateToken, authorizeRoles('admin'), studentController.updateEnrollment); router.delete('/enrollments/:enrollmentId', authenticateToken, authorizeRoles('admin'), studentController.deleteEnrollment); diff --git a/backend/src/services/ocrParser.js b/backend/src/services/ocrParser.js index ec8b080..8947427 100644 --- a/backend/src/services/ocrParser.js +++ b/backend/src/services/ocrParser.js @@ -1,3 +1,10 @@ +const fs = require('fs'); +const path = require('path'); +const xlsx = require('xlsx'); +const { execFile } = require('child_process'); +const util = require('util'); +const execFileAsync = util.promisify(execFile); + // ========================================== // SF9 PARSER (Report Card) // ========================================== @@ -74,8 +81,15 @@ exports.parseSF9 = (text) => { const syMatch = text.match(/(?:School\s*Year|S\.?Y\.?)[:\s,]*(\d{4}\s*-\s*\d{4})/i); if (syMatch) extracted.schoolYear = syMatch[1].replace(/\s/g, ''); - const trackMatch = text.match(/TRACK\/STRAND[:\s,]*([^\n,]+)/i); - if (trackMatch) extracted.trackStrand = trackMatch[1].trim(); + extracted.scholasticRecords = exports.extractAllScholasticRecords(text); + if (extracted.scholasticRecords.length > 0) { + const latest = extracted.scholasticRecords[extracted.scholasticRecords.length - 1]; + extracted.gradeLevel = latest.gradeLevel; + extracted.section = latest.section; + extracted.schoolYear = latest.schoolYear; + extracted.adviserName = latest.adviserName || ''; + extracted.semester = latest.semester || ''; + } return extracted; }; @@ -140,18 +154,92 @@ exports.parseSF10 = (text) => { const sexMatch = text.match(/(?:Sex|Gender)[:\s,]*(MALE|FEMALE|M\b|F\b)/i); if (sexMatch) extracted.sex = sexMatch[1].toUpperCase().startsWith('M') ? 'Male' : 'Female'; - const gradeMatch = text.match(/(?:GRADE\s*LEVEL|GRADE)[:\s,]*(\d+)/i); - if (gradeMatch) extracted.gradeLevel = gradeMatch[1]; - - const sectionMatch = text.match(/SECTION[:\s,]*([^\n,]+)/i); - if (sectionMatch) extracted.section = sectionMatch[1].trim(); - - const syMatch = text.match(/(?:S\.?Y\.?|School\s*Year)[:\s,]*(\d{4}\s*-\s*\d{4})/i); - if (syMatch) extracted.schoolYear = syMatch[1].replace(/\s/g, ''); + extracted.scholasticRecords = exports.extractAllScholasticRecords(text); + if (extracted.scholasticRecords.length > 0) { + const latest = extracted.scholasticRecords[extracted.scholasticRecords.length - 1]; + extracted.gradeLevel = latest.gradeLevel; + extracted.section = latest.section; + extracted.schoolYear = latest.schoolYear; + extracted.adviserName = latest.adviserName || ''; + extracted.semester = latest.semester || ''; + } return extracted; }; +// ========================================== +// EXTRACT ALL SCHOLASTIC RECORDS (PDF, Image, Excel) +// ========================================== +exports.extractAllScholasticRecords = (text) => { + const records = []; + if (!text || typeof text !== 'string') return records; + + const gradeRegex = /(?:GRADE\s*LEVEL|Classified\s*as\s*Grade|GRADE\s*:)[\s,:]*([7-9]|1[0-2])\b/gi; + let match; + while ((match = gradeRegex.exec(text)) !== null) { + const gradeLevel = match[1]; + const gNum = parseInt(gradeLevel, 10); + if (isNaN(gNum) || gNum < 7 || gNum > 12) continue; + const startIndex = match.index; + const block = text.slice(startIndex, startIndex + 600); + + // 1. Section + let section = ''; + const secMatch = block.match(/SECTION[\s,:]*([A-Za-z0-9\-\s]+?)(?=\s*(?:FIRST|SECOND|1ST|2ND|SEM|SCHOOL\s*YEAR|S\.?Y\.?|NAME\s*OF|ADVISER|TEACHER|,|\n|$))/i); + if (secMatch) { + section = secMatch[1].replace(/[,:]/g, '').trim(); + } + + // 2. School Year + let schoolYear = ''; + const syMatch = block.match(/(?:SCHOOL\s*YEAR|S\.?Y\.?)[\s,:]*(\d{4}\s*-\s*\d{4})/i); + if (syMatch) { + schoolYear = syMatch[1].replace(/\s/g, ''); + } + + // 3. Adviser / Teacher Name + let adviserName = ''; + const advMatch = block.match(/(?:NAME\s*OF\s*ADVISER\/?TEACHER|ADVISER\/?TEACHER|ADVISER|TEACHER)[\s,:]*([A-Za-z\s\-\.]+?)(?=\s*(?:SIGNATURE|DATE|LEARNING|QUARTERLY|,|\n|$))/i); + if (advMatch) { + adviserName = advMatch[1].replace(/[,:]/g, '').trim(); + adviserName = adviserName.replace(/\s*(?:Signature|Date).*$/i, '').trim(); + } + + // 4. Semester + let semester = ''; + const semMatch = block.match(/(?:SEM(?:ESTER)?|SEM)[\s,:]*(FIRST|SECOND|1ST|2ND|1|2)/i) || + block.match(/\b(FIRST|SECOND|1ST|2ND)\s*SEMESTER\b/i); + if (semMatch) { + semester = semMatch[1].toUpperCase(); + } + + if (gradeLevel && section && schoolYear) { + records.push({ + gradeLevel, + section, + schoolYear, + adviserName, + semester + }); + } + } + + // Deduplicate records: if two semesters or blocks have the same (gradeLevel, schoolYear, section), do not duplicate! + const seen = new Set(); + const deduplicated = []; + for (const rec of records) { + const key = `${rec.gradeLevel}_${rec.schoolYear}_${rec.section}`.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + deduplicated.push(rec); + } else { + console.log(`[ocrParser] Deduplicating same semester/grade record -> Grade: ${rec.gradeLevel}, SY: ${rec.schoolYear}, Sec: ${rec.section}`); + } + } + + return deduplicated; +}; + // ========================================== // HELPER FUNCTION // ========================================== @@ -238,4 +326,104 @@ function extractDob(text) { } return null; -} \ No newline at end of file +} + +// ========================================== +// EXTRACT TEXT FROM FILE (Excel, PDF, Image) +// ========================================== +exports.extractTextFromFile = async (filePath, originalName = '', mimeType = '') => { + let text = ''; + const isExcel = /\.(xlsx|xls|csv)$/i.test(originalName || filePath) || + (mimeType && (mimeType.includes('spreadsheet') || mimeType.includes('excel') || mimeType.includes('csv'))); + const isPdf = (mimeType === 'application/pdf') || /\.(pdf)$/i.test(originalName || filePath); + + if (isExcel) { + console.log('[ocrParser] Excel/Spreadsheet file detected. Reading tabs via xlsx...'); + const workbook = xlsx.readFile(filePath); + const tabTexts = []; + for (const tabName of workbook.SheetNames) { + const sheet = workbook.Sheets[tabName]; + const rows = xlsx.utils.sheet_to_json(sheet, { header: 1, defval: '' }); + const rowsText = rows + .map(row => { + return row + .filter(cell => cell !== null && cell !== undefined && String(cell).trim() !== '') + .map(cell => String(cell).trim()) + .join(' '); + }) + .filter(line => line.length > 0) + .join('\n'); + const csvText = xlsx.utils.sheet_to_csv(sheet); + tabTexts.push(`--- TAB: ${tabName} ---\n${rowsText}\n\n${csvText}`); + } + text = tabTexts.join('\n\n'); + return text; + } + + let imagePathToScan = filePath; + let tempPngPath = null; + + if (isPdf) { + console.log('[ocrParser] PDF detected. Spawning Ghostscript to convert page 1 to PNG...'); + const saveDirectory = path.resolve('./uploads/temp_ocr/'); + if (!fs.existsSync(saveDirectory)) { + fs.mkdirSync(saveDirectory, { recursive: true }); + } + tempPngPath = path.join(saveDirectory, `temp_ocr_${Date.now()}_${Math.floor(Math.random() * 1000)}.png`); + const gsArgs = [ + '-dQUIET', '-dPARANOIDSAFER', '-dBATCH', '-dNOPAUSE', '-dNOPROMPT', + '-sDEVICE=png16m', + '-dTextAlphaBits=4', '-dGraphicsAlphaBits=4', + '-r300', + '-dFirstPage=1', '-dLastPage=1', + `-sOutputFile=${tempPngPath}`, + filePath + ]; + const isWindows = process.platform === 'win32'; + try { + if (isWindows) { + try { + await execFileAsync('gswin64c', gsArgs); + } catch (err) { + if (err.code === 'ENOENT') { + try { + await execFileAsync('gs', gsArgs); + } catch (fallbackErr) { + await execFileAsync('gswin32c', gsArgs); + } + } else throw err; + } + } else { + await execFileAsync('gs', gsArgs); + } + imagePathToScan = tempPngPath; + } catch (err) { + console.error('[ocrParser] Ghostscript conversion failed:', err.message); + throw new Error('Failed to convert PDF to image for OCR: ' + err.message); + } + } + + try { + console.log('[ocrParser] Running Native Tesseract on image:', imagePathToScan); + const defaultTessData = path.join(__dirname, '..', '..', 'tesseract', 'tessdata'); + const tessEnv = { + ...process.env, + TESSDATA_PREFIX: process.env.TESSDATA_PREFIX || defaultTessData + }; + const result = await execFileAsync('tesseract', [ + imagePathToScan, + 'stdout', + '-l', 'eng' + ], { env: tessEnv, maxBuffer: 1024 * 1024 * 10 }); + text = result.stdout || ''; + } catch (err) { + console.error('[ocrParser] Tesseract execution failed:', err.message); + throw new Error('Tesseract OCR execution failed: ' + err.message); + } finally { + if (tempPngPath && fs.existsSync(tempPngPath)) { + try { fs.unlinkSync(tempPngPath); } catch (_) {} + } + } + + return text; +}; \ No newline at end of file diff --git a/backend/src/services/sfEnrollmentService.js b/backend/src/services/sfEnrollmentService.js new file mode 100644 index 0000000..118f2d7 --- /dev/null +++ b/backend/src/services/sfEnrollmentService.js @@ -0,0 +1,245 @@ +const fs = require('fs'); +const path = require('path'); +const db = require('../config/db'); +const ocrParser = require('./ocrParser'); + +const logActivity = (userId, action, entityType, entityId, description) => { + try { + db.prepare(` + INSERT INTO activity_log (user_id, action, entity_type, entity_id, description) + VALUES (?, ?, ?, ?, ?) + `).run(userId, action, entityType, entityId, description); + } catch (err) { + console.error('logActivity error:', err.message); + } +}; + +const findTeacherUser = (db, adviserName) => { + if (!adviserName || typeof adviserName !== 'string') return null; + const clean = adviserName.trim().toLowerCase(); + if (!clean) return null; + + const allUsers = db.prepare("SELECT id, first_name, last_name, username FROM users WHERE role IN ('teacher', 'admin')").all(); + for (const u of allUsers) { + const full1 = `${(u.first_name || '').trim()} ${(u.last_name || '').trim()}`.toLowerCase().replace(/\s+/g, ' '); + const full2 = `${(u.last_name || '').trim()}, ${(u.first_name || '').trim()}`.toLowerCase().replace(/\s+/g, ' '); + const userClean = (u.username || '').trim().toLowerCase(); + if (clean === full1 || clean === full2 || clean === userClean) { + return u; + } + } + return null; +}; + +exports.autoEnrollFromSF = async ({ studentId, file, documentType = '', userId = null, manual = false }) => { + // 1. If not manual, check toggle setting in system_settings + if (!manual) { + const settingRow = db.prepare("SELECT value FROM system_settings WHERE key = 'auto_update_enrollment_from_sf'").get(); + if (settingRow && (settingRow.value === 'false' || settingRow.value === '0')) { + return { + success: false, + skipped: true, + message: 'Auto-update enrollment from SF10/SF9 is disabled in system settings.' + }; + } + } + + // 2. Determine if SF10 or SF9 + const docTypeStr = String(documentType || ''); + const fileNameStr = String(file?.originalname || file?.name || ''); + const isSF10 = /sf10|sf-10|sf 10|permanent record|form 10/i.test(docTypeStr) || + /sf10|sf-10|sf 10|permanent record|form 10/i.test(fileNameStr); + const isSF9 = /sf9|sf-9|sf 9|report card|form 9/i.test(docTypeStr) || + /sf9|sf-9|sf 9|report card|form 9/i.test(fileNameStr); + + if (!isSF10 && !isSF9 && !manual) { + return { + success: false, + skipped: true, + message: 'Document is not an SF10 or SF9.' + }; + } + + if (!file || !file.path || !fs.existsSync(file.path)) { + return { + success: false, + message: 'File path not found for OCR scanning.' + }; + } + + // 3. Extract text from PDF, Image, or Excel + let text = ''; + try { + text = await ocrParser.extractTextFromFile(file.path, file.originalname || '', file.mimetype || ''); + } catch (err) { + console.error('[sfEnrollmentService] OCR/Excel text extraction error:', err.message); + return { + success: false, + message: 'Failed to extract text from document: ' + err.message + }; + } + + if (!text || text.trim().length === 0) { + return { + success: false, + message: 'No readable text extracted from document.' + }; + } + + // 4. Parse text using SF10 or SF9 parser + const docLabel = isSF10 ? 'SF10' : (isSF9 ? 'SF9' : 'SF Document'); + let extracted = isSF10 ? ocrParser.parseSF10(text) : ocrParser.parseSF9(text); + if (!extracted.schoolYear && !extracted.gradeLevel && !extracted.section && !isSF10) { + // Fallback check: if we tried SF9 and got nothing, try SF10 just in case + extracted = ocrParser.parseSF10(text); + } else if (!extracted.schoolYear && !extracted.gradeLevel && !extracted.section && isSF10) { + extracted = ocrParser.parseSF9(text); + } + + const { schoolYear, gradeLevel, section, trackStrand } = extracted; + + // 5. Gather all records to enroll (use deduplicated scholasticRecords or fallback to single extracted) + let recordsToEnroll = Array.isArray(extracted.scholasticRecords) && extracted.scholasticRecords.length > 0 + ? extracted.scholasticRecords + : [{ + gradeLevel: extracted.gradeLevel, + section: extracted.section, + schoolYear: extracted.schoolYear, + adviserName: extracted.adviserName || '', + semester: extracted.semester || '', + trackStrand: extracted.trackStrand || null + }]; + + // Deduplicate: if 2 sem is the same, do not duplicate + const uniqueRecords = []; + const seenSemKey = new Set(); + for (const rec of recordsToEnroll) { + const gradeNum = parseInt(String(rec.gradeLevel).replace(/\D/g, ''), 10); + if (isNaN(gradeNum) || gradeNum <= 0 || !rec.section) continue; + + const syStr = (rec.schoolYear || '').trim(); + const secName = (rec.section || '').trim(); + const dedupeKey = `${gradeNum}_${syStr}_${secName}`.toLowerCase(); + if (!seenSemKey.has(dedupeKey)) { + seenSemKey.add(dedupeKey); + uniqueRecords.push(rec); + } + } + + if (uniqueRecords.length === 0) { + return { + success: false, + message: `Could not extract Grade Level and Section from ${docLabel}. Found -> Grade: "${gradeLevel || ''}", Section: "${section || ''}"` + }; + } + + let lastAction = ''; + let processedRecords = []; + + for (const rec of uniqueRecords) { + const gradeNum = parseInt(String(rec.gradeLevel).replace(/\D/g, ''), 10); + const sectionName = rec.section.trim(); + let syStr = (rec.schoolYear || '').trim(); + + // 5a. Resolve Academic Year + let ayRow = null; + if (syStr) { + ayRow = db.prepare('SELECT id, year_range FROM academic_years WHERE year_range = ?').get(syStr); + if (!ayRow && /^\d{4}-\d{4}$/.test(syStr)) { + const res = db.prepare("INSERT INTO academic_years (year_range, status) VALUES (?, 'inactive')").run(syStr); + ayRow = { id: res.lastInsertRowid, year_range: syStr }; + } + } + if (!ayRow) { + ayRow = db.prepare("SELECT id, year_range FROM academic_years WHERE status = 'active' LIMIT 1").get(); + } + if (!ayRow) continue; + + // 6. Resolve or Create Section + let secRow = db.prepare(` + SELECT id FROM sections + WHERE LOWER(name) = LOWER(?) AND grade_level = ? AND (academic_year_id = ? OR academic_year_id IS NULL) + `).get(sectionName, gradeNum, ayRow.id); + + if (!secRow) { + const res = db.prepare(` + INSERT INTO sections (name, grade_level, academic_year_id) + VALUES (?, ?, ?) + `).run(sectionName, gradeNum, ayRow.id); + secRow = { id: res.lastInsertRowid }; + } + + // 6b. Check Teacher Name -> do not fill out if the name of teacher is not on the database users + if (rec.adviserName) { + const teacherUser = findTeacherUser(db, rec.adviserName); + if (teacherUser) { + const existingTeacherSec = db.prepare(` + SELECT id FROM teacher_sections WHERE teacher_id = ? AND section_id = ? + `).get(teacherUser.id, secRow.id); + if (!existingTeacherSec) { + db.prepare(` + INSERT INTO teacher_sections (teacher_id, section_id) VALUES (?, ?) + `).run(teacherUser.id, secRow.id); + } + } else { + console.log(`[sfEnrollmentService] Teacher "${rec.adviserName}" not found in database users. Skipping teacher assignment.`); + } + } + + // 7. Check Existing Enrollment + const existingEnrollment = db.prepare(` + SELECT * FROM enrollments + WHERE student_id = ? AND academic_year_id = ? AND grade_level = ? + LIMIT 1 + `).get(studentId, ayRow.id, gradeNum); + + const cleanTrackStrand = (rec.trackStrand || trackStrand) ? (rec.trackStrand || trackStrand).trim() : null; + + if (existingEnrollment) { + if (existingEnrollment.section_id !== secRow.id || + (cleanTrackStrand && existingEnrollment.track_strand !== cleanTrackStrand)) { + db.prepare(` + UPDATE enrollments + SET section_id = ?, track_strand = ? + WHERE id = ? + `).run(secRow.id, cleanTrackStrand || existingEnrollment.track_strand || null, existingEnrollment.id); + lastAction = 'updated'; + } else { + lastAction = 'already_up_to_date'; + } + } else { + db.prepare(` + INSERT INTO enrollments (student_id, academic_year_id, section_id, grade_level, track_strand) + VALUES (?, ?, ?, ?, ?) + `).run(studentId, ayRow.id, secRow.id, gradeNum, cleanTrackStrand); + lastAction = 'added'; + } + + processedRecords.push({ + gradeLevel: String(gradeNum), + section: sectionName, + schoolYear: ayRow.year_range, + adviserName: rec.adviserName || '', + semester: rec.semester || '', + action: lastAction + }); + } + + // 8. Log Activity + const student = db.prepare('SELECT first_name, last_name, lrn FROM students WHERE id = ?').get(studentId); + const studentName = student ? `${student.first_name} ${student.last_name}`.trim() : `Student #${studentId}`; + const latestRec = processedRecords[processedRecords.length - 1] || {}; + const logMsg = `Successful OCR scan of ${docLabel} for ${studentName} - Processed ${processedRecords.length} enrollment record(s): latest SY ${latestRec.schoolYear || 'N/A'}, Grade ${latestRec.gradeLevel || 'N/A'}, Section ${latestRec.section || 'N/A'}`; + logActivity(userId || null, 'CREATE', 'enrollment', studentId, logMsg); + + return { + success: true, + action: lastAction || 'processed', + schoolYear: latestRec.schoolYear || schoolYear, + gradeLevel: latestRec.gradeLevel || gradeLevel, + section: latestRec.section || section, + trackStrand: trackStrand || null, + message: logMsg, + allRecords: processedRecords + }; +}; diff --git a/frontend/lib/domain/repositories/settings_repository.dart b/frontend/lib/domain/repositories/settings_repository.dart new file mode 100644 index 0000000..b0e46fa --- /dev/null +++ b/frontend/lib/domain/repositories/settings_repository.dart @@ -0,0 +1,48 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../../core/network/api_constants.dart'; + +class SettingsRepository { + final Dio _dio = ApiConstants.createDio(); + final FlutterSecureStorage _storage = const FlutterSecureStorage(); + + Future _getAuthOptions() async { + _dio.options.baseUrl = ApiConstants.baseUrl; + final token = await _storage.read(key: 'jwt_token'); + return Options(headers: {'Authorization': 'Bearer $token'}); + } + + Future> getSettings() async { + try { + final options = await _getAuthOptions(); + final response = await _dio.get('/settings', options: options); + final data = response.data; + if (data != null && data['data'] != null) { + final map = data['data'] as Map; + return map.map((key, value) => MapEntry(key, value.toString())); + } + return {'auto_update_enrollment_from_sf': 'true'}; + } catch (e) { + return {'auto_update_enrollment_from_sf': 'true'}; + } + } + + Future> updateSettings(Map updates) async { + try { + final options = await _getAuthOptions(); + final response = await _dio.put( + '/settings', + data: {'settings': updates}, + options: options, + ); + final data = response.data; + if (data != null && data['data'] != null) { + final map = data['data'] as Map; + return map.map((key, value) => MapEntry(key, value.toString())); + } + return updates; + } catch (e) { + rethrow; + } + } +} diff --git a/frontend/lib/ui/providers/system_settings_provider.dart b/frontend/lib/ui/providers/system_settings_provider.dart new file mode 100644 index 0000000..d29d6d2 --- /dev/null +++ b/frontend/lib/ui/providers/system_settings_provider.dart @@ -0,0 +1,37 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../domain/repositories/settings_repository.dart'; + +final settingsRepositoryProvider = Provider((ref) { + return SettingsRepository(); +}); + +final systemSettingsProvider = + AsyncNotifierProvider>( + SystemSettingsNotifier.new, +); + +class SystemSettingsNotifier extends AsyncNotifier> { + @override + Future> build() async { + final repository = ref.read(settingsRepositoryProvider); + return await repository.getSettings(); + } + + Future updateSetting(String key, String value) async { + final current = state.asData?.value ?? {}; + final updated = Map.from(current)..[key] = value; + state = AsyncData(updated); + + try { + final repository = ref.read(settingsRepositoryProvider); + final serverUpdated = await repository.updateSettings({key: value}); + state = AsyncData({ + ...updated, + ...serverUpdated, + }); + } catch (e, st) { + state = AsyncError(e, st); + ref.invalidateSelf(); + } + } +} diff --git a/frontend/lib/ui/screens/documents/widgets/student_profile_modal.dart b/frontend/lib/ui/screens/documents/widgets/student_profile_modal.dart index 281cd3a..fbd0171 100644 --- a/frontend/lib/ui/screens/documents/widgets/student_profile_modal.dart +++ b/frontend/lib/ui/screens/documents/widgets/student_profile_modal.dart @@ -6,6 +6,11 @@ import '../../../../domain/repositories/document_repository.dart' import '../../../providers/student_provider.dart'; import '../../../providers/document_provider.dart'; import '../../students/widgets/edit_enrollment_modal.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../../../../core/network/api_constants.dart'; +import '../../../shared/dialogs/success_dialog.dart'; +import '../../../shared/dialogs/error_dialog.dart'; // ───────────────────────────────────────────────────────────── // Public helper – call this anywhere to show the modal @@ -342,27 +347,46 @@ class StudentProfileModalBody extends ConsumerWidget { ), if (!hideEnrollmentActions && (userRole == 'admin' || userRole == 'super_admin')) - TextButton.icon( - onPressed: () { - showDialog( - context: context, - barrierColor: Colors.black.withValues(alpha: 0.45), - builder: (ctx) => EditEnrollmentModal( - studentId: studentId, - enrollment: null, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton.icon( + onPressed: () => _scanSF10SF9(context, ref, studentId), + icon: const Icon(Icons.document_scanner, size: 16), + label: const Text('Scan SF10 / SF9'), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - ); - }, - icon: const Icon(Icons.add, size: 16), - label: const Text('Add Enrollment'), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, ), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: () { + showDialog( + context: context, + barrierColor: Colors.black.withValues(alpha: 0.45), + builder: (ctx) => EditEnrollmentModal( + studentId: studentId, + enrollment: null, + ), + ); + }, + icon: const Icon(Icons.add, size: 16), + label: const Text('Add Enrollment'), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], ), ], ), @@ -949,18 +973,6 @@ class StudentProfileModalBody extends ConsumerWidget { ); } - Widget _chip(String text, Color color) => Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - text, - style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.w600), - ), - ); - Widget _reqItem(String name, Color color, IconData icon) => Padding( padding: const EdgeInsets.only(bottom: 5), child: Row( @@ -977,4 +989,82 @@ class StudentProfileModalBody extends ConsumerWidget { if (date is DateTime) return '${date.month}/${date.day}/${date.year}'; return date.toString(); } + + Future _scanSF10SF9( + BuildContext context, WidgetRef ref, int studentId) async { + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => const Center( + child: Card( + child: Padding( + padding: EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: AppColors.primaryGreen), + SizedBox(height: 16), + Text('Scanning SF10/SF9 document...'), + ], + ), + ), + ), + ), + ); + + try { + final dio = ApiConstants.createDio(); + const storage = FlutterSecureStorage(); + final token = await storage.read(key: 'jwt_token'); + dio.options.baseUrl = ApiConstants.baseUrl; + final res = await dio.post( + '/students/$studentId/ocr-enrollment', + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + if (context.mounted) Navigator.pop(context); + + final data = res.data?['data'] ?? {}; + final allRecords = data['allRecords']; + String summaryMsg = 'SF10/SF9 Scanned Successfully!'; + if (allRecords is List && allRecords.isNotEmpty) { + summaryMsg += '\n\nProcessed ${allRecords.length} enrollment record(s):'; + for (final rec in allRecords) { + if (rec is Map) { + final gr = rec['gradeLevel'] ?? '?'; + final sy = rec['schoolYear'] ?? '?'; + final sec = rec['section'] ?? '?'; + summaryMsg += '\n• Grade $gr ($sy) - Sec: $sec'; + } + } + } else { + final sy = data['schoolYear'] ?? 'N/A'; + final gr = data['gradeLevel'] ?? 'N/A'; + final sec = data['section'] ?? 'N/A'; + summaryMsg += '\n\nExtracted Academic Year: $sy\nGrade Level: $gr\nSection: $sec'; + } + + ref.invalidate(studentPageProvider); + ref.invalidate(studentDetailProvider(studentId)); + + if (context.mounted) { + showSuccessDialog( + context, + message: summaryMsg, + ); + } + } catch (e) { + if (context.mounted) Navigator.pop(context); + String errMsg = 'Failed to scan SF10/SF9.'; + if (e is DioException && e.response?.data != null) { + final resData = e.response?.data; + if (resData is Map && resData['message'] != null) { + errMsg = resData['message'].toString(); + } + } + if (context.mounted) { + showErrorDialog(context, 'OCR Scan Failed', errMsg); + } + } + } } + diff --git a/frontend/lib/ui/screens/settings/settings_screen.dart b/frontend/lib/ui/screens/settings/settings_screen.dart index 87a79b8..8dab195 100644 --- a/frontend/lib/ui/screens/settings/settings_screen.dart +++ b/frontend/lib/ui/screens/settings/settings_screen.dart @@ -21,6 +21,7 @@ import 'teacher_management_screen.dart'; import '../../../core/utils/date_utils.dart' as pht; import '../../providers/reports_provider.dart'; import '../../../core/network/api_constants.dart'; +import '../../providers/system_settings_provider.dart'; class TitleCaseTextInputFormatter extends TextInputFormatter { @override TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { @@ -1477,6 +1478,67 @@ class _SettingsScreenState extends ConsumerState { ), ), const SizedBox(height: AppSizes.p24), + + // ── Student Enrollment Auto-Update (Admin Only) ── + Consumer( + builder: (context, ref, _) { + final sysSettingsAsync = ref.watch(systemSettingsProvider); + final settingsMap = sysSettingsAsync.asData?.value ?? {}; + final isAutoEnrollEnabled = + (settingsMap['auto_update_enrollment_from_sf'] ?? 'true') == 'true'; + + return _buildCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.auto_awesome_outlined, + color: AppColors.primaryGreen, + ), + const SizedBox(width: AppSizes.p8), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Student Enrollment Auto-Update', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Automatically extract Academic Year, Grade Level, and Section from uploaded or scanned SF10/SF9 documents to update student enrollment records.', + style: TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Switch( + value: isAutoEnrollEnabled, + activeColor: AppColors.primaryGreen, + onChanged: (val) { + ref + .read(systemSettingsProvider.notifier) + .updateSetting( + 'auto_update_enrollment_from_sf', + val ? 'true' : 'false', + ); + }, + ), + ], + ), + ], + ), + ); + }, + ), + const SizedBox(height: AppSizes.p24), ], // ── Change Password Card ──────────────────────────────