feat(bulk-import): add live CSV input, format guides, and validation preview for students and academic structure
This commit is contained in:
1 parent
32cff2431a
commit
13bbdf9e26
9 files changed
+2522
-115
No files matched your search
@@ -327,3 +327,138 @@ exports.deleteGradeLevel = (req, res) => {
|
||||
res.status(500).json({ message: 'Failed to delete grade level', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/setup/bulk-academic-structure (admin only)
|
||||
exports.bulkCreateAcademicStructure = (req, res) => {
|
||||
const { rows } = req.body;
|
||||
if (!rows || !Array.isArray(rows) || rows.length === 0) {
|
||||
return res.status(400).json({ message: 'rows array is required and must not be empty' });
|
||||
}
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const regex = /^(\d{4})\s*-\s*(\d{4})$/;
|
||||
|
||||
const results = {
|
||||
totalRows: rows.length,
|
||||
createdYears: [],
|
||||
createdSections: [],
|
||||
existingSections: [],
|
||||
errors: []
|
||||
};
|
||||
|
||||
const processTransaction = db.transaction(() => {
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const rawYear = (row.academicYear || row.yearRange || '').toString().trim();
|
||||
const rawGrade = parseInt(row.gradeLevel, 10);
|
||||
const rawSection = (row.sectionName || row.section || '').toString().trim();
|
||||
|
||||
if (!rawYear || isNaN(rawGrade) || !rawSection) {
|
||||
results.errors.push({
|
||||
row: i + 1,
|
||||
data: row,
|
||||
error: 'Academic Year, Grade Level (numeric), and Section Name are required'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate year format YYYY-YYYY
|
||||
const match = rawYear.match(regex);
|
||||
if (!match) {
|
||||
results.errors.push({
|
||||
row: i + 1,
|
||||
data: row,
|
||||
error: `Invalid academic year format "${rawYear}". Must be YYYY-YYYY (e.g. 2024-2025)`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const startYear = parseInt(match[1], 10);
|
||||
const endYear = parseInt(match[2], 10);
|
||||
if (endYear !== startYear + 1) {
|
||||
results.errors.push({
|
||||
row: i + 1,
|
||||
data: row,
|
||||
error: `Invalid academic year range "${rawYear}". End year must be start year + 1`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (startYear > currentYear) {
|
||||
results.errors.push({
|
||||
row: i + 1,
|
||||
data: row,
|
||||
error: `Cannot add future academic year "${rawYear}" beyond ${currentYear}-${currentYear + 1}`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rawGrade < 7 || rawGrade > 12) {
|
||||
results.errors.push({
|
||||
row: i + 1,
|
||||
data: row,
|
||||
error: `Grade level ${rawGrade} must be between 7 and 12`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedRange = `${startYear}-${endYear}`;
|
||||
|
||||
// 1. Find or create academic year
|
||||
let yearRecord = db.prepare('SELECT id, year_range, status FROM academic_years WHERE year_range = ?').get(normalizedRange);
|
||||
if (!yearRecord) {
|
||||
const newYear = db.prepare('INSERT INTO academic_years (year_range, status) VALUES (?, ?)')
|
||||
.run(normalizedRange, 'inactive');
|
||||
yearRecord = { id: newYear.lastInsertRowid, year_range: normalizedRange, status: 'inactive' };
|
||||
if (!results.createdYears.includes(normalizedRange)) {
|
||||
results.createdYears.push(normalizedRange);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Ensure grade level exists in grade_levels table
|
||||
const gradeExists = db.prepare('SELECT id FROM grade_levels WHERE level = ?').get(rawGrade);
|
||||
if (!gradeExists) {
|
||||
db.prepare('INSERT OR IGNORE INTO grade_levels (level, name) VALUES (?, ?)')
|
||||
.run(rawGrade, `Grade ${rawGrade}`);
|
||||
}
|
||||
|
||||
// 3. Find or create section
|
||||
const existingSection = db.prepare(
|
||||
'SELECT id FROM sections WHERE LOWER(name) = LOWER(?) AND grade_level = ? AND academic_year_id = ?'
|
||||
).get(rawSection, rawGrade, yearRecord.id);
|
||||
|
||||
if (existingSection) {
|
||||
results.existingSections.push({
|
||||
academicYear: normalizedRange,
|
||||
gradeLevel: rawGrade,
|
||||
sectionName: rawSection
|
||||
});
|
||||
} else {
|
||||
const newSec = db.prepare('INSERT INTO sections (name, grade_level, academic_year_id) VALUES (?, ?, ?)')
|
||||
.run(rawSection, rawGrade, yearRecord.id);
|
||||
results.createdSections.push({
|
||||
id: newSec.lastInsertRowid,
|
||||
academicYear: normalizedRange,
|
||||
gradeLevel: rawGrade,
|
||||
sectionName: rawSection
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
processTransaction();
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Processed ${results.totalRows} rows. Created ${results.createdSections.length} section(s) across ${results.createdYears.length} new academic year(s).`,
|
||||
data: results
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('bulkCreateAcademicStructure error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to process bulk academic structure import',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -19,6 +19,7 @@ router.delete('/academic-years/:id', authenticateToken, authorizeRoles('admin'),
|
||||
router.get('/sections', authenticateToken, setupController.getAllSections);
|
||||
router.get('/academic-years/:yearId/sections', authenticateToken, setupController.getSectionsByYear);
|
||||
router.post('/sections', authenticateToken, authorizeRoles('admin'), setupController.createSection);
|
||||
router.post('/bulk-academic-structure', authenticateToken, authorizeRoles('admin'), setupController.bulkCreateAcademicStructure);
|
||||
router.put('/sections/:id', authenticateToken, authorizeRoles('admin'), setupController.updateSection);
|
||||
router.delete('/sections/:id', authenticateToken, authorizeRoles('admin'), setupController.deleteSection);
|
||||
|
||||
|
||||
+1
-15
@@ -865,26 +865,12 @@ Files backup/restore - mirror mode or zip -->
|
||||
- add on-screen CSV format example / reference & sample rows
|
||||
- add live type / paste CSV input area with instant parse, validation preview, and bulk submit
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
# DONT DO
|
||||
<!-- - documents screen -> add how many files can be uploaded into a document requirement, the current limitation is only 1, so we can dynamically choose number files that can be uploaded into that document, update both frontend and backend, but first give me suggestion where will put the setting of this, ask me first -->
|
||||
|
||||
- print list history, print email to the student make it optional, add date of print, add status (done, not done, in progress, picked up), add message to email can be manual message typed by registrar/staff,
|
||||
|
||||
- teacher no assigned section theme color
|
||||
- bug in bulk add student enrollment
|
||||
|
||||
- recycle bin -> search history not delete history
|
||||
|
||||
- add missing students by document type kpi
|
||||
|
||||
|
||||
- notification separate admin and teacher
|
||||
- bug in bulk add student enrollment
|
||||
|
||||
- report all filter and export
|
||||
---
|
||||
|
||||
# TIS_RMS Server Manager (dont do)
|
||||
|
||||
@@ -184,6 +184,24 @@ class SetupRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> bulkCreateAcademicStructure(
|
||||
List<Map<String, dynamic>> rows,
|
||||
) async {
|
||||
try {
|
||||
final options = await _getAuthOptions();
|
||||
final response = await _dio.post(
|
||||
'/setup/bulk-academic-structure',
|
||||
options: options,
|
||||
data: {'rows': rows},
|
||||
);
|
||||
return Map<String, dynamic>.from(response.data as Map);
|
||||
} on DioException catch (e) {
|
||||
throw Exception(
|
||||
e.response?.data?['message'] ?? 'Failed to import academic structure.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateSection({
|
||||
required int id,
|
||||
required String name,
|
||||
|
||||
@@ -151,6 +151,24 @@ class SetupMutationNotifier extends AsyncNotifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> bulkCreateAcademicStructure(
|
||||
List<Map<String, dynamic>> rows,
|
||||
) async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final repo = ref.read(setupRepositoryProvider);
|
||||
final res = await repo.bulkCreateAcademicStructure(rows);
|
||||
state = const AsyncData(null);
|
||||
ref.invalidate(academicYearsListProvider);
|
||||
ref.invalidate(gradeLevelsListProvider);
|
||||
ref.invalidate(sectionsListProvider);
|
||||
return res;
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateSection({
|
||||
required int id,
|
||||
required String name,
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../../../domain/entities/system_user.dart';
|
||||
import '../../shared/dialogs/error_dialog.dart';
|
||||
import '../../shared/dialogs/success_dialog.dart';
|
||||
import '../../shared/modals/custom_modal.dart';
|
||||
import 'widgets/bulk_academic_import_modal.dart';
|
||||
|
||||
// Fixed grade levels 7-12 — no backend management needed
|
||||
const List<int> kGradeLevels = [7, 8, 9, 10, 11, 12];
|
||||
@@ -614,6 +615,24 @@ class _AcademicYearsTab extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primaryGreen,
|
||||
side: const BorderSide(color: AppColors.primaryGreen),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
),
|
||||
onPressed: () => BulkAcademicImportModal.show(context),
|
||||
icon: const Icon(Icons.upload_file, size: 16),
|
||||
label: const Text('BULK CSV'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
@@ -922,6 +941,29 @@ class _SectionsTabState extends ConsumerState<_SectionsTab>
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
height: 42,
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primaryGreen,
|
||||
side: const BorderSide(color: AppColors.primaryGreen),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 0,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onPressed: () => BulkAcademicImportModal.show(context),
|
||||
icon: const Icon(Icons.upload_file, size: 16),
|
||||
label: const Text(
|
||||
'BULK CSV',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
height: 42,
|
||||
child: ElevatedButton.icon(
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
import '../../../../core/constants/app_colors.dart';
|
||||
import '../../../../domain/entities/setup_models.dart';
|
||||
import '../../../providers/setup_provider.dart';
|
||||
import '../../../shared/dialogs/success_dialog.dart';
|
||||
import '../../../shared/dialogs/error_dialog.dart';
|
||||
import '../../../shared/modals/custom_modal.dart';
|
||||
|
||||
const List<int> _kValidGradeLevels = [7, 8, 9, 10, 11, 12];
|
||||
|
||||
class _ParsedAcademicRow {
|
||||
final int rowIndex;
|
||||
final String academicYear;
|
||||
final int gradeLevel;
|
||||
final String sectionName;
|
||||
final bool isNewYear;
|
||||
final bool isDuplicate;
|
||||
final List<String> errors;
|
||||
|
||||
bool get isValid => errors.isEmpty && !isDuplicate;
|
||||
|
||||
_ParsedAcademicRow({
|
||||
required this.rowIndex,
|
||||
required this.academicYear,
|
||||
required this.gradeLevel,
|
||||
required this.sectionName,
|
||||
required this.isNewYear,
|
||||
required this.isDuplicate,
|
||||
required this.errors,
|
||||
});
|
||||
}
|
||||
|
||||
class BulkAcademicImportModal extends ConsumerStatefulWidget {
|
||||
const BulkAcademicImportModal({super.key});
|
||||
|
||||
static Future<void> show(BuildContext context) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black54,
|
||||
builder: (_) => const BulkAcademicImportModal(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ConsumerState<BulkAcademicImportModal> createState() =>
|
||||
_BulkAcademicImportModalState();
|
||||
}
|
||||
|
||||
class _BulkAcademicImportModalState
|
||||
extends ConsumerState<BulkAcademicImportModal> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
List<_ParsedAcademicRow> _parsedRows = [];
|
||||
bool _hasParsed = false;
|
||||
bool _isSubmitting = false;
|
||||
|
||||
static const String _templateCsv =
|
||||
'Academic Year,Grade Level,Section Name\n'
|
||||
'2024-2025,7,Rizal\n'
|
||||
'2024-2025,7,Bonifacio\n'
|
||||
'2024-2025,8,Mabini\n'
|
||||
'2024-2025,9,Emerald\n'
|
||||
'2024-2025,10,Diamond\n'
|
||||
'2024-2025,11,STEM 1\n'
|
||||
'2024-2025,12,HUMSS 1';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _copyTemplate() {
|
||||
Clipboard.setData(const ClipboardData(text: _templateCsv));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('CSV example template copied to clipboard!'),
|
||||
duration: Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickCsvFile() async {
|
||||
try {
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['csv', 'txt'],
|
||||
);
|
||||
if (result != null && result.files.single.path != null) {
|
||||
final file = File(result.files.single.path!);
|
||||
final content = await file.readAsString();
|
||||
setState(() {
|
||||
_textController.text = content;
|
||||
});
|
||||
_parseInput();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showErrorDialog(context, 'File Error', 'Failed to read CSV file: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _parseInput() {
|
||||
final text = _textController.text.trim();
|
||||
if (text.isEmpty) {
|
||||
setState(() {
|
||||
_parsedRows = [];
|
||||
_hasParsed = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final existingYears = ref.read(academicYearsListProvider).value ?? [];
|
||||
final existingSections = ref.read(sectionsListProvider).value ?? [];
|
||||
|
||||
final lines = const LineSplitter().convert(text);
|
||||
final List<_ParsedAcademicRow> rows = [];
|
||||
final currentYear = DateTime.now().year;
|
||||
final regex = RegExp(r'^(\d{4})\s*-\s*(\d{4})$');
|
||||
|
||||
// Track rows inside this batch to detect intra-batch duplicates
|
||||
final Set<String> batchKeys = {};
|
||||
|
||||
int rowCounter = 0;
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
final line = lines[i].trim();
|
||||
if (line.isEmpty) continue;
|
||||
|
||||
// Handle comma, tab, or semicolon
|
||||
List<String> parts;
|
||||
if (line.contains('\t')) {
|
||||
parts = line.split('\t').map((p) => p.trim()).toList();
|
||||
} else if (line.contains(';')) {
|
||||
parts = line.split(';').map((p) => p.trim()).toList();
|
||||
} else {
|
||||
parts = line.split(',').map((p) => p.trim()).toList();
|
||||
}
|
||||
|
||||
// Check header line
|
||||
final lower0 = parts.isNotEmpty ? parts[0].toLowerCase() : '';
|
||||
if (i == 0 &&
|
||||
(lower0.contains('academic') ||
|
||||
lower0.contains('year') ||
|
||||
lower0.contains('grade') ||
|
||||
lower0.contains('section'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rowCounter++;
|
||||
final List<String> errors = [];
|
||||
|
||||
final rawYear = parts.isNotEmpty ? parts[0] : '';
|
||||
final rawGradeStr = parts.length > 1 ? parts[1] : '';
|
||||
final rawSection = parts.length > 2 ? parts[2] : '';
|
||||
|
||||
String academicYear = rawYear;
|
||||
int gradeLevel = 0;
|
||||
String sectionName = rawSection;
|
||||
|
||||
// Validate Academic Year
|
||||
final yearMatch = regex.firstMatch(rawYear);
|
||||
if (yearMatch == null) {
|
||||
errors.add('Year must be YYYY-YYYY (e.g. 2024-2025)');
|
||||
} else {
|
||||
final startY = int.tryParse(yearMatch.group(1)!) ?? 0;
|
||||
final endY = int.tryParse(yearMatch.group(2)!) ?? 0;
|
||||
if (endY != startY + 1) {
|
||||
errors.add('End year must be start + 1');
|
||||
} else if (startY > currentYear) {
|
||||
errors.add('Cannot exceed current year ($currentYear-${currentYear + 1})');
|
||||
} else {
|
||||
academicYear = '$startY-$endY';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Grade Level
|
||||
final parsedGrade = int.tryParse(
|
||||
rawGradeStr.toLowerCase().replaceAll('grade', '').replaceAll('gr', '').trim(),
|
||||
);
|
||||
if (parsedGrade == null) {
|
||||
errors.add('Grade level must be numeric (7–12)');
|
||||
} else if (!_kValidGradeLevels.contains(parsedGrade)) {
|
||||
errors.add('Grade $parsedGrade is outside valid range (7–12)');
|
||||
} else {
|
||||
gradeLevel = parsedGrade;
|
||||
}
|
||||
|
||||
// Validate Section Name
|
||||
if (sectionName.isEmpty) {
|
||||
errors.add('Section name cannot be empty');
|
||||
}
|
||||
|
||||
// Check if Year is new
|
||||
final isNewYear = errors.isEmpty &&
|
||||
!existingYears.any((y) => y.yearRange == academicYear);
|
||||
|
||||
// Check if Section already exists
|
||||
bool isDuplicate = false;
|
||||
if (errors.isEmpty) {
|
||||
final existingYear = existingYears.cast<AcademicYearModel?>().firstWhere(
|
||||
(y) => y?.yearRange == academicYear,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
if (existingYear != null) {
|
||||
final alreadyExistsInDb = existingSections.any(
|
||||
(s) =>
|
||||
s.academicYearId == existingYear.id &&
|
||||
s.gradeLevel == gradeLevel &&
|
||||
s.name.toLowerCase() == sectionName.toLowerCase(),
|
||||
);
|
||||
if (alreadyExistsInDb) {
|
||||
isDuplicate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check intra-batch duplicate
|
||||
final batchKey = '$academicYear|$gradeLevel|${sectionName.toLowerCase()}';
|
||||
if (batchKeys.contains(batchKey)) {
|
||||
isDuplicate = true;
|
||||
errors.add('Duplicate section in CSV batch');
|
||||
} else {
|
||||
batchKeys.add(batchKey);
|
||||
}
|
||||
}
|
||||
|
||||
rows.add(
|
||||
_ParsedAcademicRow(
|
||||
rowIndex: rowCounter,
|
||||
academicYear: academicYear,
|
||||
gradeLevel: gradeLevel,
|
||||
sectionName: sectionName,
|
||||
isNewYear: isNewYear,
|
||||
isDuplicate: isDuplicate,
|
||||
errors: errors,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_parsedRows = rows;
|
||||
_hasParsed = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleImport() async {
|
||||
final validRows = _parsedRows.where((r) => r.isValid).toList();
|
||||
if (validRows.isEmpty) {
|
||||
showErrorDialog(
|
||||
context,
|
||||
'No Valid Sections',
|
||||
'There are no valid, non-duplicate sections to import. Please review the errors in the table.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
final payload = validRows
|
||||
.map((r) => {
|
||||
'academicYear': r.academicYear,
|
||||
'gradeLevel': r.gradeLevel,
|
||||
'sectionName': r.sectionName,
|
||||
})
|
||||
.toList();
|
||||
|
||||
try {
|
||||
final result = await ref
|
||||
.read(setupMutationProvider.notifier)
|
||||
.bulkCreateAcademicStructure(payload);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final message = result['message']?.toString() ??
|
||||
'Successfully imported ${validRows.length} section(s)!';
|
||||
|
||||
Navigator.of(context).pop();
|
||||
showSuccessDialog(
|
||||
context,
|
||||
title: 'Bulk Import Completed',
|
||||
message: message,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showErrorDialog(
|
||||
context,
|
||||
'Import Failed',
|
||||
e.toString().replaceAll('Exception: ', ''),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final validCount = _parsedRows.where((r) => r.isValid).length;
|
||||
final errorCount = _parsedRows.where((r) => r.errors.isNotEmpty).length;
|
||||
final dupCount = _parsedRows.where((r) => r.isDuplicate && r.errors.isEmpty).length;
|
||||
final newYearCount = _parsedRows.where((r) => r.isNewYear && r.isValid).map((r) => r.academicYear).toSet().length;
|
||||
|
||||
return CustomModal(
|
||||
title: 'Bulk Setup: Academic Years & Sections',
|
||||
icon: Icons.table_chart_rounded,
|
||||
maxWidth: 820,
|
||||
content: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Template guide
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.primaryGreen.withValues(alpha: 0.1)
|
||||
: const Color(0xFFF0FAF4),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryGreen.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.lightbulb_outline_rounded,
|
||||
size: 18,
|
||||
color: AppColors.primaryGreen,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'CSV Format: Academic Year, Grade Level, Section Name',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark
|
||||
? AppColors.darkTextPrimary
|
||||
: AppColors.primaryGreen,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: _copyTemplate,
|
||||
icon: const Icon(Icons.copy_rounded, size: 14),
|
||||
label: const Text('Copy Template', style: TextStyle(fontSize: 12)),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.primaryGreen,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Provide 3 columns per row (comma, tab, or semicolon separated):\n'
|
||||
'• Academic Year: YYYY-YYYY format (e.g. 2024-2025)\n'
|
||||
'• Grade Level: 7, 8, 9, 10, 11, or 12\n'
|
||||
'• Section Name: e.g. Rizal, Diamond, STEM 1\n'
|
||||
'*Note: New academic years will be safely created as inactive.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1.4,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Controls row
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Input or Paste CSV Text:',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark
|
||||
? AppColors.darkTextPrimary
|
||||
: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickCsvFile,
|
||||
icon: const Icon(Icons.file_upload_outlined, size: 15),
|
||||
label: const Text('Browse File', style: TextStyle(fontSize: 12)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
visualDensity: VisualDensity.compact,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (data?.text != null && data!.text!.isNotEmpty) {
|
||||
setState(() {
|
||||
_textController.text = data.text!;
|
||||
});
|
||||
_parseInput();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.paste_rounded, size: 15),
|
||||
label: const Text('Paste', style: TextStyle(fontSize: 12)),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
_textController.clear();
|
||||
setState(() {
|
||||
_parsedRows = [];
|
||||
_hasParsed = false;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.clear, size: 15),
|
||||
label: const Text('Clear', style: TextStyle(fontSize: 12)),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.error,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Text Area
|
||||
TextField(
|
||||
controller: _textController,
|
||||
minLines: 4,
|
||||
maxLines: 7,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Courier',
|
||||
fontSize: 12.5,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Paste or type CSV rows here...\n2024-2025, 7, Rizal\n2024-2025, 10, Diamond',
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Courier',
|
||||
fontSize: 12,
|
||||
color: isDark ? AppColors.darkTextMuted : Colors.grey.shade400,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: isDark ? AppColors.darkSurfaceCard : Colors.grey.shade50,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: isDark ? AppColors.darkBorder : Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: isDark ? AppColors.darkBorder : Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(
|
||||
color: AppColors.primaryGreen,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
onChanged: (_) => _parseInput(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: _parseInput,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 16),
|
||||
label: const Text('Parse & Preview', style: TextStyle(fontSize: 12)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
),
|
||||
),
|
||||
if (_hasParsed)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
_buildStatusBadge(
|
||||
label: '$validCount Valid',
|
||||
color: AppColors.primaryGreen,
|
||||
),
|
||||
if (newYearCount > 0)
|
||||
_buildStatusBadge(
|
||||
label: '$newYearCount New Year${newYearCount > 1 ? 's' : ''}',
|
||||
color: Colors.blue,
|
||||
),
|
||||
if (dupCount > 0)
|
||||
_buildStatusBadge(
|
||||
label: '$dupCount Duplicate${dupCount > 1 ? 's' : ''}',
|
||||
color: Colors.orange,
|
||||
),
|
||||
if (errorCount > 0)
|
||||
_buildStatusBadge(
|
||||
label: '$errorCount Error${errorCount > 1 ? 's' : ''}',
|
||||
color: AppColors.error,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Live Preview / Validation Table
|
||||
if (_hasParsed) ...[
|
||||
Text(
|
||||
'Live Validation Preview (${_parsedRows.length} rows):',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark
|
||||
? AppColors.darkTextPrimary
|
||||
: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isDark ? AppColors.darkBorder : Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
child: _parsedRows.isEmpty
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No rows detected. Paste CSV rows above to view live preview.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: _parsedRows.length,
|
||||
separatorBuilder: (_, unused) => Divider(
|
||||
height: 1,
|
||||
color: isDark
|
||||
? AppColors.darkBorder
|
||||
: Colors.grey.shade200,
|
||||
),
|
||||
itemBuilder: (context, idx) {
|
||||
final row = _parsedRows[idx];
|
||||
Color statusColor;
|
||||
IconData statusIcon;
|
||||
String statusText;
|
||||
|
||||
if (row.errors.isNotEmpty) {
|
||||
statusColor = AppColors.error;
|
||||
statusIcon = Icons.cancel_outlined;
|
||||
statusText = row.errors.join('; ');
|
||||
} else if (row.isDuplicate) {
|
||||
statusColor = Colors.orange;
|
||||
statusIcon = Icons.warning_amber_rounded;
|
||||
statusText = 'Already exists in database (skipped)';
|
||||
} else if (row.isNewYear) {
|
||||
statusColor = Colors.blue;
|
||||
statusIcon = Icons.add_circle_outline_rounded;
|
||||
statusText = 'Valid (will create new Academic Year)';
|
||||
} else {
|
||||
statusColor = AppColors.primaryGreen;
|
||||
statusIcon = Icons.check_circle_outline_rounded;
|
||||
statusText = 'Valid';
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(statusIcon, color: statusColor, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 32,
|
||||
child: Text(
|
||||
'#${row.rowIndex}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isDark
|
||||
? AppColors.darkTextMuted
|
||||
: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
row.academicYear,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
row.gradeLevel > 0
|
||||
? 'Grade ${row.gradeLevel}'
|
||||
: '—',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
row.sectionName.isNotEmpty
|
||||
? row.sectionName
|
||||
: '—',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Footer Actions
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: () => Navigator.of(context).pop(),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isSubmitting || validCount == 0
|
||||
? null
|
||||
: _handleImport,
|
||||
icon: _isSubmitting
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.check, size: 16),
|
||||
label: Text(
|
||||
_isSubmitting
|
||||
? 'Importing...'
|
||||
: 'Import $validCount Section${validCount == 1 ? '' : 's'}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 18,
|
||||
vertical: 11,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBadge({required String label, required Color color}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1243,20 +1243,24 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
// "Bulk Add" button for Windows
|
||||
// "Bulk Import" button for Windows / Desktop
|
||||
if (defaultTargetPlatform == TargetPlatform.windows &&
|
||||
widget.userRole != 'teacher') ...[
|
||||
const SizedBox(width: 6),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _openBulkOcrImport,
|
||||
icon: const Icon(Icons.document_scanner_outlined, size: 18),
|
||||
label: const Text('Bulk Add', style: TextStyle(fontSize: 13)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.darkGreen,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
Tooltip(
|
||||
message: 'Bulk Import Students (OCR & CSV)',
|
||||
child: SizedBox(
|
||||
height: 36,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _openBulkOcrImport,
|
||||
icon: const Icon(Icons.group_add_outlined, size: 18),
|
||||
label: const Text('Bulk Import',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.darkGreen,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user