feat: archives file menu, copy (1) naming, excel to pdf, and OCR enrollment validation
- archives: per-file menu (preview/print/download) via icon, right-click, long-press - documents: copy renames to "name (1).ext" with incrementing collision handling - documents: convert xls/xlsx/csv to multi-sheet PDF saved in student folder (excel kept) - students: OCR enrollment scan is review-only with 7-12 grade gate and manual validation modal - add student: linear OCR progress, SF9/SF10 grade 7-12 validation with auto-fill enrollment
This commit is contained in:
1 parent
2ac3a791ce
commit
c5998468b7
13 files changed
+1242
-180
No files matched your search
@@ -644,7 +644,15 @@ exports.copyDocument = (req, res) => {
|
||||
|
||||
const ext = path.extname(doc.file_name);
|
||||
const baseName = path.basename(doc.file_name, ext);
|
||||
const newFileName = `${baseName} - Copy${ext}`;
|
||||
const existingNames = db.prepare(
|
||||
'SELECT file_name FROM documents WHERE student_id = ? AND deleted_at IS NULL'
|
||||
).all(doc.student_id).map((r) => r.file_name);
|
||||
let counter = 1;
|
||||
let newFileName = `${baseName} (${counter})${ext}`;
|
||||
while (existingNames.includes(newFileName)) {
|
||||
counter++;
|
||||
newFileName = `${baseName} (${counter})${ext}`;
|
||||
}
|
||||
|
||||
const dir = path.dirname(originalPath);
|
||||
const newFilePath = path.join(dir, `${Date.now()}-${newFileName.replace(/[^a-zA-Z0-9.-]/g, '_')}`);
|
||||
@@ -819,6 +827,7 @@ exports.bulkCopy = (req, res) => {
|
||||
INSERT INTO documents (student_id, requirement_id, file_name, file_path, document_type, status, uploaded_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const usedNames = new Set();
|
||||
|
||||
for (const id of ids) {
|
||||
const doc = getStmt.get(id);
|
||||
@@ -843,7 +852,16 @@ exports.bulkCopy = (req, res) => {
|
||||
if (fs.existsSync(originalPath)) {
|
||||
const ext = path.extname(doc.file_name);
|
||||
const baseName = path.basename(doc.file_name, ext);
|
||||
const newFileName = `${baseName} - Copy${ext}`;
|
||||
const existingNames = db.prepare(
|
||||
'SELECT file_name FROM documents WHERE student_id = ? AND deleted_at IS NULL'
|
||||
).all(doc.student_id).map((r) => r.file_name);
|
||||
let counter = 1;
|
||||
let newFileName = `${baseName} (${counter})${ext}`;
|
||||
while (existingNames.includes(newFileName) || usedNames.has(newFileName)) {
|
||||
counter++;
|
||||
newFileName = `${baseName} (${counter})${ext}`;
|
||||
}
|
||||
usedNames.add(newFileName);
|
||||
const dir = path.dirname(originalPath);
|
||||
const newFilePath = path.join(dir, `${Date.now()}-${newFileName.replace(/[^a-zA-Z0-9.-]/g, '_')}`);
|
||||
|
||||
|
||||
@@ -888,45 +888,48 @@ exports.scanEnrollmentFromSF = async (req, res) => {
|
||||
}
|
||||
|
||||
const sfEnrollmentService = require('../services/sfEnrollmentService');
|
||||
let lastResult = null;
|
||||
let successCount = 0;
|
||||
let results = [];
|
||||
let aggregatedRecords = [];
|
||||
const seenKey = new Set();
|
||||
const records = [];
|
||||
let lastExtracted = null;
|
||||
let lastError = null;
|
||||
|
||||
for (const doc of docs) {
|
||||
const resData = await sfEnrollmentService.autoEnrollFromSF({
|
||||
studentId,
|
||||
const result = await sfEnrollmentService.extractEnrollmentData({
|
||||
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 (!result.ok) {
|
||||
lastError = result.message;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
lastExtracted = result.extracted;
|
||||
for (const rec of result.uniqueRecords) {
|
||||
const gradeNum = parseInt(String(rec.gradeLevel).replace(/\D/g, ''), 10);
|
||||
const key = `${gradeNum}_${(rec.schoolYear || '').trim()}_${(rec.section || '').trim()}`.toLowerCase();
|
||||
if (seenKey.has(key)) continue;
|
||||
seenKey.add(key);
|
||||
records.push({
|
||||
gradeLevel: String(gradeNum),
|
||||
section: (rec.section || '').trim(),
|
||||
schoolYear: (rec.schoolYear || '').trim(),
|
||||
adviserName: rec.adviserName || '',
|
||||
semester: rec.semester || ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (aggregatedRecords.length > 0) {
|
||||
lastResult.allRecords = aggregatedRecords;
|
||||
if (records.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: lastError || 'No Grade 7-12 enrollment records could be extracted from the SF10/SF9 documents.'
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `Successfully scanned SF10/SF9 and processed ${aggregatedRecords.length || 1} enrollment record(s).`,
|
||||
data: lastResult
|
||||
message: `Scanned ${records.length} enrollment record(s). Review before saving.`,
|
||||
data: { extracted: lastExtracted, records }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('scanEnrollmentFromSF error:', error);
|
||||
|
||||
@@ -31,6 +31,101 @@ const findTeacherUser = (db, adviserName) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Extract and parse enrollment data from an SF9/SF10 document without writing to the database.
|
||||
// Records are gated to Grade 7-12.
|
||||
exports.extractEnrollmentData = async ({ file, documentType = '', manual = false }) => {
|
||||
// 1. 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 {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
message: 'Document is not an SF10 or SF9.'
|
||||
};
|
||||
}
|
||||
|
||||
if (!file || !file.path || !fs.existsSync(file.path)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'File path not found for OCR scanning.'
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 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 {
|
||||
ok: false,
|
||||
message: 'Failed to extract text from document: ' + err.message
|
||||
};
|
||||
}
|
||||
|
||||
if (!text || text.trim().length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'No readable text extracted from document.'
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 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;
|
||||
|
||||
// 4. 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. Only Grade 7-12 records are accepted.
|
||||
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 < 7 || gradeNum > 12 || !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 {
|
||||
ok: false,
|
||||
message: `Could not extract a Grade 7-12 enrollment from ${docLabel}. Found -> Grade: "${gradeLevel || ''}", Section: "${section || ''}"`
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, isSF10, isSF9, docLabel, extracted, uniqueRecords };
|
||||
};
|
||||
|
||||
exports.autoEnrollFromSF = async ({ studentId, file, documentType = '', userId = null, manual = false }) => {
|
||||
// 1. If not manual, check toggle setting in system_settings
|
||||
if (!manual) {
|
||||
@@ -44,95 +139,19 @@ exports.autoEnrollFromSF = async ({ studentId, file, documentType = '', userId =
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 2. Extract + parse records (Grade 7-12 only, no writes here)
|
||||
const data = await exports.extractEnrollmentData({ file, documentType, manual });
|
||||
if (!data.ok) {
|
||||
return {
|
||||
success: false,
|
||||
skipped: true,
|
||||
message: 'Document is not an SF10 or SF9.'
|
||||
skipped: data.skipped || false,
|
||||
message: data.message
|
||||
};
|
||||
}
|
||||
|
||||
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 { docLabel, extracted, uniqueRecords } = data;
|
||||
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 = [];
|
||||
|
||||
|
||||
+20
-10
@@ -88,7 +88,7 @@ Files backup/restore - mirror mode or zip -->
|
||||
<!-- ## backend server backup/restore
|
||||
- do not use zip and unzip -->
|
||||
|
||||
# ANDROID APP
|
||||
<!-- # ANDROID APP
|
||||
|
||||
# DOCUMENT SCREEN
|
||||
- add back prevention active multi select
|
||||
@@ -115,9 +115,7 @@ Files backup/restore - mirror mode or zip -->
|
||||
- 1st back, navigates back folder 'Student Folders' tab
|
||||
- 2nd back, navigates to dashboard screen
|
||||
- if the open folder is not present
|
||||
- 1st back, navigate to dashboard
|
||||
|
||||
|
||||
- 1st back, navigate to dashboard -->
|
||||
|
||||
<!-- # DOCUMENTS SCREEN
|
||||
1. apply dark mode
|
||||
@@ -130,6 +128,23 @@ Files backup/restore - mirror mode or zip -->
|
||||
- fix searched field didnt hide when searched an user -->
|
||||
|
||||
|
||||
# ARCHIVES SCREEN
|
||||
- add files menu for print, download, hold for android app, right click for windows app, add icon menu for easy access
|
||||
|
||||
# DOCUMENTS SCREEN
|
||||
- copy file rename format same as orginal file name add (1) or incrementing number if name already exist
|
||||
- add new feature -> excel to pdf: all tabs export into one pdf, hold/right click, or icon menu for excel files then make a convertion but in pdf do not delete the excel file
|
||||
|
||||
# STUDENT SCREEN
|
||||
1. edit student screen -> enrollments tab -> auto enrollment OCR:
|
||||
- do not add in database if not 7-12 grade level detect
|
||||
- add manual validation to add year level, grade level, section before saving to database
|
||||
- in manual validation add actions accept, decline, or delete, use only icons.
|
||||
- for icon only icons no container, no background, no border
|
||||
2. Add new student screen:
|
||||
- processing OCR remove the circle loading
|
||||
- auto add enrollment if the OCR scan uploaded is SF9/SF10
|
||||
|
||||
---
|
||||
|
||||
# TIS_RMS Server Manager (dont do)
|
||||
@@ -148,13 +163,8 @@ Windows Service - NSSM nodejs
|
||||
---
|
||||
|
||||
## (dont do) add some features
|
||||
- archives screen, add files menu for print, download
|
||||
- copy files name
|
||||
- auto enrollment OCR do not add in database if not 7-12 grade level detect
|
||||
- auto add enrollment in add student if the OCR upload is SF9/SF10
|
||||
- dark mode -> ongoing
|
||||
- documents screen
|
||||
- excel to pdf: all tabs export into one pdf
|
||||
|
||||
|
||||
|
||||
---
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'package:excel/excel.dart' hide Border;
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
|
||||
/// Converts an Excel workbook into a single multi-page PDF — one page (or
|
||||
/// chunk of pages) per sheet tab, rendered as a bordered grid.
|
||||
Future<Uint8List> convertExcelBytesToPdf(
|
||||
List<int> bytes, {
|
||||
int maxRows = 500,
|
||||
int maxCols = 30,
|
||||
}) async {
|
||||
final excel = Excel.decodeBytes(bytes);
|
||||
final doc = pw.Document();
|
||||
final sheetNames = excel.tables.keys.toList();
|
||||
|
||||
if (sheetNames.isEmpty) {
|
||||
doc.addPage(_noDataPage('Workbook contains no sheets.'));
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
for (final name in sheetNames) {
|
||||
final sheet = excel.tables[name];
|
||||
if (sheet == null || sheet.rows.isEmpty) continue;
|
||||
final rows = _extractSheetRows(sheet, maxRows, maxCols);
|
||||
if (rows.isEmpty) continue;
|
||||
|
||||
final colCount = rows
|
||||
.map((r) => r.length)
|
||||
.fold<int>(0, (acc, len) => len > acc ? len : acc);
|
||||
|
||||
const rowsPerPage = 60;
|
||||
final pageCount = (rows.length / rowsPerPage).ceil();
|
||||
for (var start = 0; start < rows.length; start += rowsPerPage) {
|
||||
final end = math.min(start + rowsPerPage, rows.length);
|
||||
final pageNo = start ~/ rowsPerPage + 1;
|
||||
doc.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat.legal,
|
||||
margin: const pw.EdgeInsets.all(16),
|
||||
build: (_) => pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
name,
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColors.green700,
|
||||
),
|
||||
),
|
||||
if (pageCount > 1) ...[
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(
|
||||
'Page $pageNo of $pageCount',
|
||||
style: pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
|
||||
),
|
||||
],
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(
|
||||
color: PdfColors.grey400,
|
||||
width: 0.4,
|
||||
),
|
||||
columnWidths: {
|
||||
for (var c = 0; c < colCount; c++)
|
||||
c: pw.FlexColumnWidth(1),
|
||||
},
|
||||
children: _buildChunkRows(start, end, rows, colCount),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
pw.Page _noDataPage(String message) {
|
||||
return pw.Page(
|
||||
pageFormat: PdfPageFormat.legal,
|
||||
build: (_) => pw.Center(
|
||||
child: pw.Text(message, style: const pw.TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<List<String>> _extractSheetRows(Sheet sheet, int maxRows, int maxCols) {
|
||||
final rows = <List<String>>[];
|
||||
for (var r = 0; r < sheet.rows.length && r < maxRows; r++) {
|
||||
final row = sheet.rows[r];
|
||||
final rowCells = <String>[];
|
||||
final colLimit = math.min(row.length, maxCols);
|
||||
for (var c = 0; c < colLimit; c++) {
|
||||
rowCells.add(_cellToString(row[c]));
|
||||
}
|
||||
if (rowCells.any((v) => v.trim().isNotEmpty)) {
|
||||
rows.add(rowCells);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
String _cellToString(Data? cell) {
|
||||
if (cell == null || cell.value == null) return '';
|
||||
final val = cell.value;
|
||||
try {
|
||||
if (val is TextCellValue) return val.value.toString();
|
||||
if (val is IntCellValue) return val.value.toString();
|
||||
if (val is DoubleCellValue) return val.value.toString();
|
||||
if (val is BoolCellValue) return val.value ? 'TRUE' : 'FALSE';
|
||||
return val.toString();
|
||||
} catch (_) {
|
||||
return val.toString();
|
||||
}
|
||||
}
|
||||
|
||||
List<pw.TableRow> _buildChunkRows(
|
||||
int start,
|
||||
int end,
|
||||
List<List<String>> rows,
|
||||
int colCount,
|
||||
) {
|
||||
final out = <pw.TableRow>[];
|
||||
for (var r = start; r < end; r++) {
|
||||
final row = rows[r];
|
||||
final cells = <pw.Widget>[];
|
||||
for (var c = 0; c < colCount; c++) {
|
||||
cells.add(_cellWidget(c < row.length ? row[c] : '', bold: r == 0));
|
||||
}
|
||||
out.add(pw.TableRow(children: cells));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
pw.Widget _cellWidget(String text, {required bool bold}) {
|
||||
return pw.Container(
|
||||
padding: const pw.EdgeInsets.all(2),
|
||||
child: pw.Text(
|
||||
text,
|
||||
style: pw.TextStyle(
|
||||
fontSize: 7,
|
||||
fontWeight: bold ? pw.FontWeight.bold : pw.FontWeight.normal,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../../core/network/api_constants.dart';
|
||||
@@ -321,6 +322,34 @@ class DocumentRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads in-memory bytes (e.g. a converted PDF) as a new document in the
|
||||
/// student's folder.
|
||||
Future<void> uploadDocumentBytes({
|
||||
required int studentId,
|
||||
required String documentType,
|
||||
int? requirementId,
|
||||
required String fileName,
|
||||
required Uint8List bytes,
|
||||
}) async {
|
||||
try {
|
||||
final options = await _getAuthOptions();
|
||||
final formData = FormData.fromMap({
|
||||
'studentId': studentId,
|
||||
'documentType': documentType,
|
||||
if (requirementId != null) 'requirementId': requirementId,
|
||||
'document': MultipartFile.fromBytes(
|
||||
bytes,
|
||||
filename: fileName,
|
||||
contentType: DioMediaType('application', 'pdf'),
|
||||
),
|
||||
});
|
||||
await _dio.post('/documents/upload', data: formData, options: options);
|
||||
} on DioException catch (e) {
|
||||
final msg = e.response?.data?['message'] ?? 'Failed to upload document.';
|
||||
throw Exception(msg);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> bulkDeleteDocuments(List<int> ids) async {
|
||||
try {
|
||||
final options = await _getAuthOptions();
|
||||
|
||||
@@ -35,6 +35,33 @@ class StudentPage {
|
||||
}
|
||||
}
|
||||
|
||||
/// A single enrollment record extracted from an SF9/SF10 OCR scan.
|
||||
class OcrEnrollmentRecord {
|
||||
final String gradeLevel;
|
||||
final String section;
|
||||
final String schoolYear;
|
||||
final String adviserName;
|
||||
final String semester;
|
||||
|
||||
const OcrEnrollmentRecord({
|
||||
required this.gradeLevel,
|
||||
required this.section,
|
||||
required this.schoolYear,
|
||||
this.adviserName = '',
|
||||
this.semester = '',
|
||||
});
|
||||
|
||||
factory OcrEnrollmentRecord.fromJson(Map<String, dynamic> json) {
|
||||
return OcrEnrollmentRecord(
|
||||
gradeLevel: (json['gradeLevel'] as String?) ?? '',
|
||||
section: (json['section'] as String?) ?? '',
|
||||
schoolYear: (json['schoolYear'] as String?) ?? '',
|
||||
adviserName: (json['adviserName'] as String?) ?? '',
|
||||
semester: (json['semester'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StudentRepository {
|
||||
final Dio _dio = ApiConstants.createDio();
|
||||
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
||||
@@ -441,14 +468,18 @@ class StudentRepository {
|
||||
// ----------------------------------------------------------------
|
||||
// Manual SF9/SF10 OCR Scan Enrollment
|
||||
// ----------------------------------------------------------------
|
||||
Future<Map<String, dynamic>> scanEnrollmentFromSF(int studentId) async {
|
||||
Future<List<OcrEnrollmentRecord>> scanEnrollmentFromSF(int studentId) async {
|
||||
try {
|
||||
final options = await _getAuthOptions();
|
||||
final response = await _dio.post(
|
||||
'/students/$studentId/ocr-enrollment',
|
||||
options: options,
|
||||
);
|
||||
return response.data as Map<String, dynamic>;
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final records = (data['data'] as Map<String, dynamic>?)?['records'] as List?;
|
||||
return (records ?? [])
|
||||
.map((r) => OcrEnrollmentRecord.fromJson(r as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
final msg = e.response?.data?['message'] ?? 'Failed to scan SF10/SF9 for enrollment.';
|
||||
throw Exception(msg);
|
||||
|
||||
@@ -466,14 +466,12 @@ class StudentMutationNotifier extends AsyncNotifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> scanEnrollmentFromSF(int studentId) async {
|
||||
Future<List<OcrEnrollmentRecord>> scanEnrollmentFromSF(int studentId) async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final repo = ref.read(studentRepositoryProvider);
|
||||
final result = await repo.scanEnrollmentFromSF(studentId);
|
||||
state = const AsyncData(null);
|
||||
ref.invalidate(studentDetailProvider(studentId));
|
||||
ref.invalidate(studentPageProvider);
|
||||
return result;
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
|
||||
@@ -17,6 +17,10 @@ import '../../shared/inputs/custom_text_field.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/navigation_provider.dart';
|
||||
import '../documents/widgets/document_preview_modal.dart';
|
||||
import '../documents/widgets/print_queue_modal.dart';
|
||||
import '../../../core/utils/download_service.dart';
|
||||
import '../../../core/network/api_constants.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class ArchivesScreen extends ConsumerStatefulWidget {
|
||||
final String userRole;
|
||||
@@ -436,6 +440,104 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
showDocumentPreview(context: context, document: doc);
|
||||
}
|
||||
|
||||
// ── Document file menu (print, download, preview) ──────────────
|
||||
Future<void> _handleDocumentAction(String action, DocumentModel doc) async {
|
||||
if (action == 'preview') {
|
||||
_handlePreview(doc);
|
||||
} else if (action == 'print') {
|
||||
try {
|
||||
await ref.read(printQueueMutationProvider.notifier).addToQueue(doc.id);
|
||||
} catch (_) {
|
||||
// Already in the print list - still open the queue.
|
||||
}
|
||||
if (!mounted) return;
|
||||
PrintQueueModal.show(context);
|
||||
} else if (action == 'download') {
|
||||
try {
|
||||
final token =
|
||||
await const FlutterSecureStorage().read(key: 'jwt_token');
|
||||
if (token == null) return;
|
||||
final url =
|
||||
'${ApiConstants.baseUrl}/documents/${doc.id}/view?token=$token&download=true';
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Download started...'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
await DownloadService.downloadFile(
|
||||
url: url,
|
||||
fileName: doc.fileName,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
showSuccessDialog(
|
||||
context,
|
||||
message: 'Document downloaded successfully.',
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
showErrorDialog(context, 'Download Failed', e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<PopupMenuEntry<String>> _buildDocumentMenuItems() {
|
||||
return const [
|
||||
PopupMenuItem(
|
||||
value: 'preview',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.visibility_outlined, size: 18),
|
||||
SizedBox(width: 12),
|
||||
Text('Preview', style: TextStyle(fontSize: 14)),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'print',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.print, size: 18),
|
||||
SizedBox(width: 12),
|
||||
Text('Print', style: TextStyle(fontSize: 14)),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'download',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.download, size: 18),
|
||||
SizedBox(width: 12),
|
||||
Text('Download', style: TextStyle(fontSize: 14)),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void _showDocumentContextMenu(
|
||||
BuildContext context,
|
||||
Offset position,
|
||||
DocumentModel doc,
|
||||
) {
|
||||
if (_isMultiSelectMode) return;
|
||||
final RenderBox overlay =
|
||||
Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||
|
||||
showMenu<String>(
|
||||
context: context,
|
||||
position: RelativeRect.fromRect(
|
||||
position & const Size(40, 40),
|
||||
Offset.zero & overlay.size,
|
||||
),
|
||||
items: _buildDocumentMenuItems(),
|
||||
).then((value) {
|
||||
if (value != null) _handleDocumentAction(value, doc);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final query = ref.watch(archiveDocumentQueryProvider);
|
||||
@@ -1669,7 +1771,10 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
Widget _buildDesktopListRow(DocumentModel doc) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final isSelected = _selectedDocumentIds.contains(doc.id);
|
||||
return InkWell(
|
||||
return GestureDetector(
|
||||
onSecondaryTapDown: (details) =>
|
||||
_showDocumentContextMenu(context, details.globalPosition, doc),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (_isMultiSelectMode) {
|
||||
setState(() {
|
||||
@@ -1770,25 +1875,35 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildStatusChip(doc.status)),
|
||||
if (!_isMultiSelectMode)
|
||||
SizedBox(
|
||||
width: 40,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.visibility_outlined, size: 18),
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
tooltip: 'Preview',
|
||||
onPressed: () => _handlePreview(doc),
|
||||
child: PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
iconColor: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.textSecondary,
|
||||
padding: EdgeInsets.zero,
|
||||
onSelected: (a) => _handleDocumentAction(a, doc),
|
||||
itemBuilder: (_) => _buildDocumentMenuItems(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileListRow(DocumentModel doc) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final isSelected = _selectedDocumentIds.contains(doc.id);
|
||||
return InkWell(
|
||||
return GestureDetector(
|
||||
onSecondaryTapDown: (details) =>
|
||||
_showDocumentContextMenu(context, details.globalPosition, doc),
|
||||
onLongPressStart: (details) =>
|
||||
_showDocumentContextMenu(context, details.globalPosition, doc),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (_isMultiSelectMode) {
|
||||
setState(() {
|
||||
@@ -1882,18 +1997,23 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_buildStatusChip(doc.status),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.visibility_outlined, size: 20),
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
if (!_isMultiSelectMode)
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert, size: 20),
|
||||
iconColor: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.textSecondary,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () => _handlePreview(doc),
|
||||
onSelected: (a) => _handleDocumentAction(a, doc),
|
||||
itemBuilder: (_) => _buildDocumentMenuItems(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1925,7 +2045,12 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
itemBuilder: (ctx, i) {
|
||||
final doc = documents[i];
|
||||
final isSelected = _selectedDocumentIds.contains(doc.id);
|
||||
return InkWell(
|
||||
return GestureDetector(
|
||||
onSecondaryTapDown: (details) =>
|
||||
_showDocumentContextMenu(ctx, details.globalPosition, doc),
|
||||
onLongPressStart: (details) =>
|
||||
_showDocumentContextMenu(ctx, details.globalPosition, doc),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (_isMultiSelectMode) {
|
||||
setState(() {
|
||||
@@ -1940,7 +2065,9 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppColors.primaryGreen.withValues(alpha: 0.08)
|
||||
@@ -2019,6 +2146,24 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!_isMultiSelectMode)
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert, size: 16),
|
||||
iconColor: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.textSecondary,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onSelected: (a) => _handleDocumentAction(a, doc),
|
||||
itemBuilder: (_) => _buildDocumentMenuItems(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -27,6 +27,7 @@ import '../../../core/network/api_constants.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'widgets/recycle_bin_modal.dart';
|
||||
import '../../../domain/entities/document_model.dart';
|
||||
import '../../../core/utils/excel_to_pdf_converter.dart';
|
||||
|
||||
class DocumentsScreen extends ConsumerStatefulWidget {
|
||||
final String userRole;
|
||||
@@ -306,6 +307,8 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
|
||||
if (!mounted) return;
|
||||
showErrorDialog(context, 'Copy Failed', e.toString());
|
||||
}
|
||||
} else if (action == 'convert_pdf') {
|
||||
await _handleConvertToPdf(document);
|
||||
} else if (action == 'preview') {
|
||||
showDocumentPreview(context: context, document: document);
|
||||
} else if (action == 'download') {
|
||||
@@ -337,6 +340,52 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
|
||||
}
|
||||
}
|
||||
|
||||
bool _isExcelFile(DocumentModel doc) {
|
||||
final name = doc.fileName.toLowerCase();
|
||||
return name.endsWith('.xls') || name.endsWith('.xlsx') || name.endsWith('.csv');
|
||||
}
|
||||
|
||||
Future<void> _handleConvertToPdf(DocumentModel doc) async {
|
||||
if (doc.studentId == null) {
|
||||
showErrorDialog(context, 'Conversion Failed', 'No student folder found for this document.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final bytes =
|
||||
await ref.read(documentRepositoryProvider).downloadDocumentBytes(doc.id);
|
||||
if (!mounted) return;
|
||||
final pdfBytes = await convertExcelBytesToPdf(bytes);
|
||||
if (pdfBytes.length > 10 * 1024 * 1024) {
|
||||
showErrorDialog(
|
||||
context,
|
||||
'Conversion Failed',
|
||||
'Converted PDF exceeds the 10MB upload limit.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final baseName = doc.fileName.replaceFirst(
|
||||
RegExp(r'\.(xls|xlsx|csv)$', caseSensitive: false),
|
||||
'',
|
||||
);
|
||||
await ref.read(documentRepositoryProvider).uploadDocumentBytes(
|
||||
studentId: doc.studentId!,
|
||||
documentType: doc.documentType ?? 'Document',
|
||||
requirementId: doc.requirementId,
|
||||
fileName: '$baseName.pdf',
|
||||
bytes: pdfBytes,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ref.invalidate(documentPageProvider);
|
||||
showSuccessDialog(
|
||||
context,
|
||||
message: 'Converted to PDF and saved in the student folder.',
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showErrorDialog(context, 'Conversion Failed', e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void _confirmDelete(int id) {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -2836,6 +2885,18 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isExcelFile(doc as DocumentModel)) ...[
|
||||
const PopupMenuItem(
|
||||
value: 'convert_pdf',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.picture_as_pdf, size: 16),
|
||||
SizedBox(width: 10),
|
||||
Text('Convert to PDF', style: TextStyle(fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: 'view_profile',
|
||||
@@ -3137,18 +3198,23 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
];
|
||||
|
||||
if (_isExcelFile(doc)) {
|
||||
items.insert(
|
||||
4,
|
||||
const PopupMenuItem(
|
||||
value: 'view_profile',
|
||||
value: 'convert_pdf',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.person, size: 18, color: AppColors.primaryGreen),
|
||||
Icon(Icons.picture_as_pdf, size: 18),
|
||||
SizedBox(width: 12),
|
||||
Text('View Student Profile', style: TextStyle(fontSize: 14)),
|
||||
Text('Convert to PDF', style: TextStyle(fontSize: 14)),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.userRole != 'teacher') {
|
||||
items.add(const PopupMenuDivider());
|
||||
|
||||
@@ -6,12 +6,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/constants/app_colors.dart';
|
||||
import '../../../../core/constants/app_sizes.dart';
|
||||
import '../../../../domain/entities/setup_models.dart';
|
||||
import '../../../../domain/entities/ocr_result_model.dart';
|
||||
import '../../../../domain/repositories/student_repository.dart';
|
||||
import '../../../shared/buttons/primary_button.dart';
|
||||
import '../../../providers/setup_provider.dart';
|
||||
import '../../../providers/ocr_provider.dart';
|
||||
import '../../../providers/student_provider.dart';
|
||||
import '../../documents/widgets/document_preview_modal.dart';
|
||||
import '../../../shared/inputs/document_source_picker.dart';
|
||||
import 'ocr_enrollment_validation_modal.dart';
|
||||
import 'student_form_helpers.dart';
|
||||
|
||||
class AddStudentModal extends ConsumerStatefulWidget {
|
||||
@@ -43,6 +46,10 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
|
||||
int? _selectedSectionId;
|
||||
String? _trackStrand;
|
||||
|
||||
/// Enrollment accepted from the OCR review dialog; auto-fills the
|
||||
/// enrollment step until the student is saved.
|
||||
OcrEnrollmentPrefill? _ocrSavedEnrollment;
|
||||
|
||||
File? _ocrScannedFile;
|
||||
String? _selectedOcrDocType;
|
||||
|
||||
@@ -296,6 +303,8 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
|
||||
_selectedOcrDocType = docType;
|
||||
_currentStep = 1;
|
||||
});
|
||||
|
||||
await _offerOcrEnrollment(ocrResult, docType);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final raw = e.toString();
|
||||
@@ -308,6 +317,37 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
|
||||
}
|
||||
}
|
||||
|
||||
/// For SF9/SF10 scans with a Grade 7-12 level, let the user review and
|
||||
/// edit the detected enrollment (year level, grade level, section).
|
||||
/// On accept the enrollment is saved into [_ocrSavedEnrollment] and the
|
||||
/// enrollment step auto-fills with it.
|
||||
Future<void> _offerOcrEnrollment(
|
||||
OcrResultModel ocrResult,
|
||||
String docType,
|
||||
) async {
|
||||
if (docType != 'SF9' && docType != 'SF10') return;
|
||||
final gradeNum = int.tryParse(ocrResult.gradeLevel.replaceAll(RegExp(r'\D'), ''));
|
||||
if (gradeNum == null || gradeNum < 7 || gradeNum > 12) return;
|
||||
if (!mounted) return;
|
||||
|
||||
final prefill = await OcrEnrollmentValidationModal.showForAddStudent(
|
||||
context,
|
||||
record: OcrEnrollmentRecord(
|
||||
gradeLevel: gradeNum.toString(),
|
||||
section: ocrResult.section,
|
||||
schoolYear: ocrResult.schoolYear,
|
||||
),
|
||||
);
|
||||
if (!mounted || prefill == null) return;
|
||||
|
||||
setState(() {
|
||||
_ocrSavedEnrollment = prefill;
|
||||
_selectedAcademicYearId = prefill.academicYearId;
|
||||
_selectedGradeLevel = prefill.gradeLevel;
|
||||
_selectedSectionId = prefill.sectionId;
|
||||
});
|
||||
}
|
||||
|
||||
String? _validateLRN(String? value) {
|
||||
if (value == null || value.trim().isEmpty) return 'LRN is required.';
|
||||
if (!RegExp(r'^\d{12}$').hasMatch(value.trim())) {
|
||||
@@ -414,12 +454,19 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
if (_isLoading) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSizes.p32),
|
||||
child: Center(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: AppSizes.p32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
color: AppColors.primaryGreen,
|
||||
backgroundColor:
|
||||
isDark ? AppColors.darkSurface2 : const Color(0xFFE0E0E0),
|
||||
minHeight: 8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSizes.p16),
|
||||
Text(
|
||||
'Processing document with OCR... Please wait',
|
||||
@@ -431,7 +478,6 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
@@ -740,6 +786,40 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_ocrSavedEnrollment != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSizes.p12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryGreen.withValues(alpha: 0.07),
|
||||
borderRadius: BorderRadius.circular(AppSizes.radiusMedium),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryGreen.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 18,
|
||||
color: AppColors.primaryGreen,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Enrollment saved from OCR scan: Grade '
|
||||
'${_ocrSavedEnrollment!.gradeLevel} · '
|
||||
'${_ocrSavedEnrollment!.sectionName} · '
|
||||
'SY ${_ocrSavedEnrollment!.schoolYear} — '
|
||||
'verify before saving the student.',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSizes.p16),
|
||||
],
|
||||
const SectionLabel(label: 'ENROLLMENT DETAILS'),
|
||||
const SizedBox(height: AppSizes.p16),
|
||||
yearsAsync.when(
|
||||
@@ -942,7 +1022,7 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
|
||||
totalSteps: 3,
|
||||
currentStep: _currentStep,
|
||||
),
|
||||
if (_isLoading)
|
||||
if (_isLoading && _currentStep != 0)
|
||||
const LinearProgressIndicator(
|
||||
color: AppColors.primaryGreen,
|
||||
backgroundColor: Color(0xFFE0E0E0),
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../../shared/dialogs/success_dialog.dart';
|
||||
import '../../../shared/dialogs/error_dialog.dart';
|
||||
import '../../../providers/student_provider.dart';
|
||||
import 'edit_enrollment_modal.dart';
|
||||
import 'ocr_enrollment_validation_modal.dart';
|
||||
import 'student_form_helpers.dart';
|
||||
|
||||
class EditStudentModal extends ConsumerStatefulWidget {
|
||||
@@ -40,6 +41,7 @@ class _EditStudentModalState extends ConsumerState<EditStudentModal> {
|
||||
late bool _is4ps;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _isOcrScanning = false;
|
||||
String? _errorMessage;
|
||||
|
||||
late StudentModel _initialStudent;
|
||||
@@ -575,50 +577,48 @@ class _EditStudentModalState extends ConsumerState<EditStudentModal> {
|
||||
}
|
||||
|
||||
Future<void> _handleScanEnrollmentFromSF() 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(),
|
||||
SizedBox(height: 16),
|
||||
Text('Scanning SF9/SF10 for enrollment data...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
setState(() => _isOcrScanning = true);
|
||||
try {
|
||||
final res = await ref
|
||||
final records = await ref
|
||||
.read(studentMutationProvider.notifier)
|
||||
.scanEnrollmentFromSF(widget.student.id);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
if (!mounted) return;
|
||||
|
||||
if (records.isEmpty) {
|
||||
showErrorDialog(
|
||||
context,
|
||||
'OCR Scan Failed',
|
||||
'No Grade 7-12 enrollment records were detected from the '
|
||||
'SF9/SF10 documents.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final msg = res['message'] as String? ??
|
||||
'Successfully scanned enrollment from SF10/SF9.';
|
||||
final accepted = await OcrEnrollmentValidationModal.show(
|
||||
context,
|
||||
studentId: widget.student.id,
|
||||
records: records,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (mounted) {
|
||||
await showSuccessDialog(context, message: msg);
|
||||
ref.invalidate(studentDetailProvider(widget.student.id));
|
||||
ref.invalidate(studentPageProvider);
|
||||
|
||||
if (accepted > 0) {
|
||||
await showSuccessDialog(
|
||||
context,
|
||||
message: '$accepted enrollment record(s) added.',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
if (!mounted) return;
|
||||
final raw = e.toString();
|
||||
final msg = raw.startsWith('Exception: ') ? raw.substring(11) : raw;
|
||||
showErrorDialog(context, 'OCR Scan Failed', msg);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isOcrScanning = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,10 +642,20 @@ class _EditStudentModalState extends ConsumerState<EditStudentModal> {
|
||||
|
||||
Widget buildHeaderButtons({required bool expanded}) {
|
||||
final ocrButton = OutlinedButton.icon(
|
||||
onPressed: _handleScanEnrollmentFromSF,
|
||||
icon: const Icon(Icons.document_scanner, size: 18),
|
||||
onPressed: _isOcrScanning ? null : _handleScanEnrollmentFromSF,
|
||||
icon: _isOcrScanning
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.document_scanner, size: 18),
|
||||
label: Text(
|
||||
isMobile ? 'OCR Scan' : 'OCR Scan (SF9/SF10)',
|
||||
isMobile
|
||||
? (_isOcrScanning ? 'Scanning...' : 'OCR Scan')
|
||||
: (_isOcrScanning
|
||||
? 'Scanning...'
|
||||
: 'OCR Scan (SF9/SF10)'),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/constants/app_colors.dart';
|
||||
import '../../../../domain/entities/setup_models.dart';
|
||||
import '../../../../domain/repositories/student_repository.dart';
|
||||
import '../../../providers/setup_provider.dart';
|
||||
import '../../../providers/student_provider.dart';
|
||||
import '../../../shared/dialogs/error_dialog.dart';
|
||||
|
||||
/// Accepted enrollment chosen in add-student (prefill) mode.
|
||||
class OcrEnrollmentPrefill {
|
||||
final int academicYearId;
|
||||
final int gradeLevel;
|
||||
final int? sectionId;
|
||||
final String sectionName;
|
||||
final String schoolYear;
|
||||
|
||||
const OcrEnrollmentPrefill({
|
||||
required this.academicYearId,
|
||||
required this.gradeLevel,
|
||||
required this.sectionName,
|
||||
required this.schoolYear,
|
||||
this.sectionId,
|
||||
});
|
||||
}
|
||||
|
||||
enum _ValidationMode { editStudent, addStudent }
|
||||
|
||||
class _Entry {
|
||||
final OcrEnrollmentRecord record;
|
||||
int? academicYearId;
|
||||
int gradeLevel;
|
||||
int? sectionId;
|
||||
|
||||
_Entry({
|
||||
required this.record,
|
||||
required this.gradeLevel,
|
||||
});
|
||||
}
|
||||
|
||||
class OcrEnrollmentValidationModal extends ConsumerStatefulWidget {
|
||||
final int? studentId;
|
||||
final List<OcrEnrollmentRecord> records;
|
||||
final _ValidationMode _mode;
|
||||
|
||||
const OcrEnrollmentValidationModal._({
|
||||
super.key,
|
||||
this.studentId,
|
||||
required this.records,
|
||||
required _ValidationMode mode,
|
||||
}) : _mode = mode;
|
||||
|
||||
/// Edit-student mode: accept writes the enrollment via the API.
|
||||
/// Returns the number of accepted enrollments.
|
||||
static Future<int> show(
|
||||
BuildContext context, {
|
||||
required int studentId,
|
||||
required List<OcrEnrollmentRecord> records,
|
||||
}) async {
|
||||
final result = await showDialog<int>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => OcrEnrollmentValidationModal._(
|
||||
studentId: studentId,
|
||||
records: records,
|
||||
mode: _ValidationMode.editStudent,
|
||||
),
|
||||
);
|
||||
return result ?? 0;
|
||||
}
|
||||
|
||||
/// Add-student mode: no API writes. Returns the accepted, edited
|
||||
/// enrollment, or null when the user rejects the record.
|
||||
static Future<OcrEnrollmentPrefill?> showForAddStudent(
|
||||
BuildContext context, {
|
||||
required OcrEnrollmentRecord record,
|
||||
}) {
|
||||
return showDialog<OcrEnrollmentPrefill>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (_) => OcrEnrollmentValidationModal._(
|
||||
records: [record],
|
||||
mode: _ValidationMode.addStudent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ConsumerState<OcrEnrollmentValidationModal> createState() =>
|
||||
_OcrEnrollmentValidationModalState();
|
||||
}
|
||||
|
||||
class _OcrEnrollmentValidationModalState
|
||||
extends ConsumerState<OcrEnrollmentValidationModal> {
|
||||
late List<_Entry> _entries;
|
||||
int _acceptedCount = 0;
|
||||
int? _acceptingIndex;
|
||||
|
||||
bool get _isEditMode => widget._mode == _ValidationMode.editStudent;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_entries = widget.records.map((r) {
|
||||
final gradeNum = int.tryParse(r.gradeLevel.replaceAll(RegExp(r'\D'), ''));
|
||||
return _Entry(
|
||||
record: r,
|
||||
gradeLevel: (gradeNum != null && gradeNum >= 7 && gradeNum <= 12)
|
||||
? gradeNum
|
||||
: 7,
|
||||
);
|
||||
}).toList();
|
||||
_initDefaults();
|
||||
}
|
||||
|
||||
Future<void> _initDefaults() async {
|
||||
try {
|
||||
final years = await ref.read(academicYearsListProvider.future);
|
||||
final sections = await ref.read(sectionsListProvider.future);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
for (final e in _entries) {
|
||||
e.academicYearId = _matchYear(years, e.record.schoolYear);
|
||||
e.sectionId = _matchSection(sections, e);
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
// Lists unavailable (e.g. teacher without sections) - leave manual.
|
||||
}
|
||||
}
|
||||
|
||||
int? _matchYear(List<AcademicYearModel> years, String schoolYear) {
|
||||
final sy = schoolYear.trim();
|
||||
for (final y in years) {
|
||||
if (y.yearRange.toLowerCase() == sy.toLowerCase()) return y.id;
|
||||
}
|
||||
for (final y in years) {
|
||||
if (y.status.toLowerCase() == 'active') return y.id;
|
||||
}
|
||||
return years.isEmpty ? null : years.first.id;
|
||||
}
|
||||
|
||||
int? _matchSection(List<SectionModel> sections, _Entry entry) {
|
||||
final name = entry.record.section.trim().toLowerCase();
|
||||
for (final s in sections) {
|
||||
final yearOk = s.academicYearId == null ||
|
||||
entry.academicYearId == null ||
|
||||
s.academicYearId == entry.academicYearId;
|
||||
if (s.gradeLevel == entry.gradeLevel &&
|
||||
yearOk &&
|
||||
s.name.trim().toLowerCase() == name) {
|
||||
return s.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<SectionModel> _sectionsForEntry(
|
||||
List<SectionModel> allSections,
|
||||
_Entry entry,
|
||||
) {
|
||||
return allSections
|
||||
.where((s) =>
|
||||
s.gradeLevel == entry.gradeLevel &&
|
||||
(s.academicYearId == null ||
|
||||
entry.academicYearId == null ||
|
||||
s.academicYearId == entry.academicYearId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _accept(int index) async {
|
||||
final entry = _entries[index];
|
||||
if (entry.academicYearId == null || entry.sectionId == null) {
|
||||
showErrorDialog(
|
||||
context,
|
||||
'Incomplete',
|
||||
'Select Academic Year and Section before accepting.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isEditMode) {
|
||||
final years = ref.read(academicYearsListProvider).asData?.value ?? [];
|
||||
final year = years
|
||||
.where((y) => y.id == entry.academicYearId)
|
||||
.firstOrNull;
|
||||
final prefill = OcrEnrollmentPrefill(
|
||||
academicYearId: entry.academicYearId!,
|
||||
gradeLevel: entry.gradeLevel,
|
||||
sectionId: entry.sectionId,
|
||||
sectionName:
|
||||
entry.record.section.isEmpty ? 'Section' : entry.record.section,
|
||||
schoolYear: year?.yearRange ?? entry.record.schoolYear,
|
||||
);
|
||||
Navigator.of(context).pop(prefill);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _acceptingIndex = index);
|
||||
try {
|
||||
await ref.read(studentMutationProvider.notifier).addEnrollment(
|
||||
studentId: widget.studentId!,
|
||||
academicYearId: entry.academicYearId!,
|
||||
gradeLevel: entry.gradeLevel,
|
||||
sectionId: entry.sectionId!,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_acceptedCount++;
|
||||
_entries.removeAt(index);
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final raw = e.toString();
|
||||
final msg = raw.startsWith('Exception: ') ? raw.substring(11) : raw;
|
||||
showErrorDialog(context, 'Accept Failed', msg);
|
||||
} finally {
|
||||
if (mounted) setState(() => _acceptingIndex = null);
|
||||
}
|
||||
}
|
||||
|
||||
void _remove(int index) {
|
||||
setState(() => _entries.removeAt(index));
|
||||
}
|
||||
|
||||
void _close() {
|
||||
Navigator.of(context).pop(_isEditMode ? _acceptedCount : null);
|
||||
}
|
||||
|
||||
InputDecoration _deco(String label) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return InputDecoration(
|
||||
labelText: label,
|
||||
isDense: true,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
filled: true,
|
||||
fillColor: isDark ? AppColors.darkSurface2 : Colors.white,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final yearsAsync = ref.watch(academicYearsListProvider);
|
||||
final sectionsAsync = ref.watch(sectionsListProvider);
|
||||
final years = yearsAsync.asData?.value ?? <AcademicYearModel>[];
|
||||
final allSections = sectionsAsync.asData?.value ?? <SectionModel>[];
|
||||
final initialCount = _entries.length + _acceptedCount;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 640, maxHeight: 560),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.document_scanner,
|
||||
size: 22,
|
||||
color: AppColors.primaryGreen,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_isEditMode
|
||||
? 'Validate Enrollment from OCR'
|
||||
: 'Review Enrollment from Scan',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Edit Year Level, Grade Level, and Section. '
|
||||
'Accept to save, decline/delete to skip without saving.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_entries.isEmpty)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No records left.',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
itemCount: _entries.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) =>
|
||||
_buildEntryRow(index, years, allSections, isDark),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
_isEditMode
|
||||
? '$_acceptedCount of $initialCount accepted'
|
||||
: 'Accept to add this enrollment',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: _close,
|
||||
child: const Text(
|
||||
'CLOSE',
|
||||
style: TextStyle(
|
||||
color: AppColors.primaryGreen,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEntryRow(
|
||||
int index,
|
||||
List<AcademicYearModel> years,
|
||||
List<SectionModel> allSections,
|
||||
bool isDark,
|
||||
) {
|
||||
final entry = _entries[index];
|
||||
final sections = _sectionsForEntry(allSections, entry);
|
||||
final sectionValueOk = entry.sectionId != null &&
|
||||
sections.any((s) => s.id == entry.sectionId);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurface2 : const Color(0xFFF6F8FA),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.darkBorder
|
||||
: Colors.black.withValues(alpha: 0.06),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.record.schoolYear.isEmpty
|
||||
? 'Detected Enrollment'
|
||||
: 'Detected: ${entry.record.schoolYear} · Grade ${entry.record.gradeLevel} · ${entry.record.section}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryGreen,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Icon-only actions: no container, no background, no border.
|
||||
IconButton(
|
||||
onPressed: _acceptingIndex == index
|
||||
? null
|
||||
: () => _accept(index),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: _acceptingIndex == index
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 22,
|
||||
color: AppColors.primaryGreen,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
IconButton(
|
||||
onPressed: _acceptingIndex == index
|
||||
? null
|
||||
: () => _remove(index),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: const Icon(
|
||||
Icons.block,
|
||||
size: 20,
|
||||
color: AppColors.warning,
|
||||
),
|
||||
),
|
||||
if (_isEditMode) ...[
|
||||
const SizedBox(width: 2),
|
||||
IconButton(
|
||||
onPressed:
|
||||
_acceptingIndex == index ? null : () => _remove(index),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: const Icon(
|
||||
Icons.delete_outline,
|
||||
size: 20,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 190,
|
||||
child: DropdownButtonFormField<int>(
|
||||
initialValue: entry.academicYearId,
|
||||
isExpanded: true,
|
||||
decoration: _deco('Year Level'),
|
||||
items: years
|
||||
.map((y) => DropdownMenuItem<int>(
|
||||
value: y.id, child: Text(y.yearRange)))
|
||||
.toList(),
|
||||
onChanged: _acceptingIndex == index
|
||||
? null
|
||||
: (v) => setState(() {
|
||||
entry.academicYearId = v;
|
||||
entry.sectionId =
|
||||
_matchSection(allSections, entry);
|
||||
}),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: DropdownButtonFormField<int>(
|
||||
initialValue: entry.gradeLevel,
|
||||
isExpanded: true,
|
||||
decoration: _deco('Grade Level'),
|
||||
items: [7, 8, 9, 10, 11, 12]
|
||||
.map((g) => DropdownMenuItem<int>(
|
||||
value: g, child: Text('Grade $g')))
|
||||
.toList(),
|
||||
onChanged: _acceptingIndex == index
|
||||
? null
|
||||
: (v) => setState(() {
|
||||
entry.gradeLevel = v!;
|
||||
entry.sectionId =
|
||||
_matchSection(allSections, entry);
|
||||
}),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 170,
|
||||
child: DropdownButtonFormField<int>(
|
||||
initialValue: sectionValueOk ? entry.sectionId : null,
|
||||
isExpanded: true,
|
||||
decoration: _deco('Section'),
|
||||
items: sections
|
||||
.map((s) =>
|
||||
DropdownMenuItem<int>(value: s.id, child: Text(s.name)))
|
||||
.toList(),
|
||||
onChanged: _acceptingIndex == index
|
||||
? null
|
||||
: (v) => setState(() => entry.sectionId = v),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user