diff --git a/backend/src/controllers/setupController.js b/backend/src/controllers/setupController.js index 2760743..56eedef 100644 --- a/backend/src/controllers/setupController.js +++ b/backend/src/controllers/setupController.js @@ -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 + }); + } +}; diff --git a/backend/src/routes/setup.js b/backend/src/routes/setup.js index dfaa5a5..e8ec3d3 100644 --- a/backend/src/routes/setup.js +++ b/backend/src/routes/setup.js @@ -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); diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 04508c5..68be9d0 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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 - -- 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) diff --git a/frontend/lib/domain/repositories/setup_repository.dart b/frontend/lib/domain/repositories/setup_repository.dart index 74f6a26..cce3f68 100644 --- a/frontend/lib/domain/repositories/setup_repository.dart +++ b/frontend/lib/domain/repositories/setup_repository.dart @@ -184,6 +184,24 @@ class SetupRepository { } } + Future> bulkCreateAcademicStructure( + List> rows, + ) async { + try { + final options = await _getAuthOptions(); + final response = await _dio.post( + '/setup/bulk-academic-structure', + options: options, + data: {'rows': rows}, + ); + return Map.from(response.data as Map); + } on DioException catch (e) { + throw Exception( + e.response?.data?['message'] ?? 'Failed to import academic structure.', + ); + } + } + Future updateSection({ required int id, required String name, diff --git a/frontend/lib/ui/providers/setup_provider.dart b/frontend/lib/ui/providers/setup_provider.dart index 12cd07e..1813d7d 100644 --- a/frontend/lib/ui/providers/setup_provider.dart +++ b/frontend/lib/ui/providers/setup_provider.dart @@ -151,6 +151,24 @@ class SetupMutationNotifier extends AsyncNotifier { } } + Future> bulkCreateAcademicStructure( + List> 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 updateSection({ required int id, required String name, diff --git a/frontend/lib/ui/screens/settings/teacher_management_screen.dart b/frontend/lib/ui/screens/settings/teacher_management_screen.dart index 506ce16..e08e604 100644 --- a/frontend/lib/ui/screens/settings/teacher_management_screen.dart +++ b/frontend/lib/ui/screens/settings/teacher_management_screen.dart @@ -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 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( diff --git a/frontend/lib/ui/screens/settings/widgets/bulk_academic_import_modal.dart b/frontend/lib/ui/screens/settings/widgets/bulk_academic_import_modal.dart new file mode 100644 index 0000000..8555064 --- /dev/null +++ b/frontend/lib/ui/screens/settings/widgets/bulk_academic_import_modal.dart @@ -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 _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 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 show(BuildContext context) { + return showDialog( + context: context, + barrierColor: Colors.black54, + builder: (_) => const BulkAcademicImportModal(), + ); + } + + @override + ConsumerState createState() => + _BulkAcademicImportModalState(); +} + +class _BulkAcademicImportModalState + extends ConsumerState { + 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 _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 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 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 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().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 _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, + ), + ), + ); + } +} diff --git a/frontend/lib/ui/screens/students/students_screen.dart b/frontend/lib/ui/screens/students/students_screen.dart index 7691878..2cb2329 100644 --- a/frontend/lib/ui/screens/students/students_screen.dart +++ b/frontend/lib/ui/screens/students/students_screen.dart @@ -1243,20 +1243,24 @@ class _StudentsScreenState extends ConsumerState { ), ), ], - // "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), + ), ), ), ), diff --git a/frontend/lib/ui/screens/students/widgets/bulk_ocr_import_dialog.dart b/frontend/lib/ui/screens/students/widgets/bulk_ocr_import_dialog.dart index 8359625..2feac92 100644 --- a/frontend/lib/ui/screens/students/widgets/bulk_ocr_import_dialog.dart +++ b/frontend/lib/ui/screens/students/widgets/bulk_ocr_import_dialog.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -61,7 +62,6 @@ class _OcrItem { required this.filePath, required this.fileName, this.status = _FileStatus.pending, - this.errorMsg, this.lrn = '308035', this.firstName = '', this.middleName = '', @@ -84,6 +84,45 @@ class _OcrItem { (sex == 'Male' || sex == 'Female'); } +class _ParsedStudentRow { + final int rowIndex; + String lrn; + String firstName; + String middleName; + String lastName; + String extension; + String sex; + String dob; + bool is4ps; + final List errors; + + bool get isValid => errors.isEmpty; + + String get fullName { + final parts = [lastName, firstName, middleName, extension] + .where((p) => p.trim().isNotEmpty) + .toList(); + if (parts.isEmpty) return 'Unnamed'; + final name = '$lastName, $firstName'; + final extra = + [middleName, extension].where((p) => p.trim().isNotEmpty).join(' '); + return extra.isNotEmpty ? '$name $extra' : name; + } + + _ParsedStudentRow({ + required this.rowIndex, + required this.lrn, + required this.firstName, + required this.middleName, + required this.lastName, + required this.extension, + required this.sex, + required this.dob, + required this.is4ps, + required this.errors, + }); +} + // --------------------------------------------------------------------------- // Dialog // --------------------------------------------------------------------------- @@ -99,27 +138,49 @@ class BulkOcrImportDialog extends ConsumerStatefulWidget { class _BulkOcrImportDialogState extends ConsumerState { int _step = 0; // 0=Upload 1=Review 2=Summary + int _activeInputTab = 0; // 0=Document Files/Scan, 1=Live Type/Paste CSV final List<_OcrItem> _items = []; bool _isDragOver = false; Timer? _dragResetTimer; bool _isProcessing = false; + int _processingIndex = -1; + + // CSV Live Input & Validation + final TextEditingController _csvTextController = TextEditingController(); + List<_ParsedStudentRow> _parsedStudentRows = []; + bool _hasParsedCsv = false; + + static const String _sampleCsvTemplate = + 'LRN,First Name,Middle Name,Last Name,Extension,Sex,Birth Date,4Ps\n' + '308035123456,Juan,Protacio,Rizal,Jr.,Male,2008-06-19,Yes\n' + '308035654321,Maria,Clara,Santos,,Female,2009-11-23,No\n' + '308035987654,Andres,Castro,Bonifacio,,Male,2008-11-30,No\n' + '308035112233,Gabriela,Silang,Cariño,,Female,2009-03-19,Yes'; @override void dispose() { _dragResetTimer?.cancel(); + _csvTextController.dispose(); super.dispose(); } - int _processingIndex = -1; @override void initState() { super.initState(); final preloaded = widget.preloadedFiles; if (preloaded != null) { + File? preloadedCsv; for (final f in preloaded) { final name = f.path.split(Platform.pathSeparator).last; - _items.add(_OcrItem(filePath: f.path, fileName: name)); + if (name.toLowerCase().endsWith('.csv')) { + preloadedCsv ??= f; + } else { + _items.add(_OcrItem(filePath: f.path, fileName: name)); + } + } + if (preloadedCsv != null) { + _loadCsvFromFile(preloadedCsv); } } WidgetsBinding.instance.addPostFrameCallback((_) { @@ -160,13 +221,283 @@ class _BulkOcrImportDialogState extends ConsumerState { } void _addFiles(List files) { + final csvFiles = + files.where((f) => f.path.toLowerCase().endsWith('.csv')).toList(); + final otherFiles = + files.where((f) => !f.path.toLowerCase().endsWith('.csv')).toList(); + + if (csvFiles.isNotEmpty) { + _loadCsvFromFile(csvFiles.first); + } + + if (otherFiles.isNotEmpty) { + setState(() { + for (final f in otherFiles) { + final name = f.path.split(Platform.pathSeparator).last; + if (!_items.any((i) => i.filePath == f.path)) { + _items.add(_OcrItem(filePath: f.path, fileName: name)); + } + } + }); + } + } + + Future _loadCsvFromFile(File file) async { + try { + final content = await file.readAsString(); + if (mounted) { + setState(() { + _activeInputTab = 1; + _csvTextController.text = content; + }); + _parseCsvInput(); + } + } catch (e) { + if (mounted) { + showErrorDialog(context, 'CSV Read Error', 'Failed to read CSV file: $e'); + } + } + } + + void _copyTemplate() { + Clipboard.setData(const ClipboardData(text: _sampleCsvTemplate)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('CSV student template copied to clipboard!'), + duration: Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + ), + ); + } + + void _loadSampleData() { setState(() { - for (final f in files) { - final name = f.path.split(Platform.pathSeparator).last; - if (!_items.any((i) => i.filePath == f.path)) { - _items.add(_OcrItem(filePath: f.path, fileName: name)); + _csvTextController.text = _sampleCsvTemplate; + }); + _parseCsvInput(); + } + + Future _pickCsvFile() async { + try { + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['csv', 'txt'], + ); + if (result != null && result.files.single.path != null) { + await _loadCsvFromFile(File(result.files.single.path!)); + } + } catch (e) { + if (mounted) { + showErrorDialog(context, 'File Error', 'Failed to read file: $e'); + } + } + } + + Future _pasteFromClipboard() async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null && data!.text!.trim().isNotEmpty) { + setState(() { + _csvTextController.text = data.text!; + }); + _parseCsvInput(); + } + } + + void _clearCsvInput() { + setState(() { + _csvTextController.clear(); + _parsedStudentRows = []; + _hasParsedCsv = false; + }); + } + + List _parseCsvLine(String line) { + final List result = []; + final StringBuffer sb = StringBuffer(); + bool inQuotes = false; + final String delimiter = line.contains('\t') + ? '\t' + : (line.contains(';') && !line.contains(',') ? ';' : ','); + + for (int i = 0; i < line.length; i++) { + final char = line[i]; + if (char == '"') { + if (inQuotes && i + 1 < line.length && line[i + 1] == '"') { + sb.write('"'); + i++; + } else { + inQuotes = !inQuotes; + } + } else if (char == delimiter && !inQuotes) { + result.add(sb.toString().trim()); + sb.clear(); + } else { + sb.write(char); + } + } + result.add(sb.toString().trim()); + return result; + } + + bool _isHeaderLine(List parts) { + if (parts.isEmpty) return false; + final first = parts[0].toLowerCase(); + final joined = parts.join(' ').toLowerCase(); + return first.contains('lrn') || + first.contains('student') || + joined.contains('first name') || + joined.contains('last name') || + joined.contains('birth date'); + } + + void _parseCsvInput() { + final text = _csvTextController.text.trim(); + if (text.isEmpty) { + setState(() { + _parsedStudentRows = []; + _hasParsedCsv = false; + }); + return; + } + + final lines = const LineSplitter().convert(text); + final List<_ParsedStudentRow> parsed = []; + final Set batchLrns = {}; + int rowCounter = 0; + + for (int i = 0; i < lines.length; i++) { + final line = lines[i].trim(); + if (line.isEmpty) continue; + + final parts = _parseCsvLine(line); + if (i == 0 && _isHeaderLine(parts)) { + continue; + } + + rowCounter++; + final List errors = []; + + final rawLrn = parts.isNotEmpty + ? parts[0].replaceAll(RegExp(r'\D'), '') + : ''; + final rawFirstName = parts.length > 1 ? parts[1].trim() : ''; + final rawMiddleName = parts.length > 2 ? parts[2].trim() : ''; + final rawLastName = parts.length > 3 ? parts[3].trim() : ''; + final rawExtension = parts.length > 4 ? parts[4].trim() : ''; + final rawSex = parts.length > 5 ? parts[5].trim() : ''; + final rawDob = parts.length > 6 ? parts[6].trim() : ''; + final raw4ps = parts.length > 7 ? parts[7].trim() : ''; + + // Validate LRN + if (rawLrn.isEmpty) { + errors.add('LRN is required.'); + } else if (rawLrn.length != 12) { + errors.add('LRN must be exactly 12 digits (got ${rawLrn.length}).'); + } else if (batchLrns.contains(rawLrn)) { + errors.add('Duplicate LRN ($rawLrn) within this batch.'); + } else { + batchLrns.add(rawLrn); + } + + // Validate Names + if (rawFirstName.isEmpty) { + errors.add('First name is required.'); + } + if (rawLastName.isEmpty) { + errors.add('Last name is required.'); + } + + // Validate Sex + String normSex = ''; + final sexLower = rawSex.toLowerCase(); + if (sexLower == 'male' || sexLower == 'm') { + normSex = 'Male'; + } else if (sexLower == 'female' || sexLower == 'f') { + normSex = 'Female'; + } else if (rawSex.isEmpty) { + errors.add('Sex is required (Male/Female).'); + } else { + errors.add('Invalid sex "$rawSex" (must be Male or Female).'); + } + + // Validate Birth Date + String normDob = ''; + if (rawDob.isNotEmpty) { + final parsedDate = _parseFlexibleDob(rawDob); + if (parsedDate == null) { + errors.add('Invalid date "$rawDob" (use YYYY-MM-DD or MM/DD/YYYY).'); + } else if (parsedDate.isAfter(DateTime.now())) { + errors.add('Birth date cannot be in the future.'); + } else { + normDob = + '${parsedDate.year.toString().padLeft(4, '0')}-${parsedDate.month.toString().padLeft(2, '0')}-${parsedDate.day.toString().padLeft(2, '0')}'; } } + + // Parse 4Ps + final s4ps = raw4ps.toLowerCase(); + final is4ps = + s4ps == 'yes' || s4ps == 'y' || s4ps == 'true' || s4ps == '1'; + + parsed.add(_ParsedStudentRow( + rowIndex: rowCounter, + lrn: rawLrn, + firstName: rawFirstName, + middleName: rawMiddleName, + lastName: rawLastName, + extension: rawExtension, + sex: normSex.isNotEmpty ? normSex : rawSex, + dob: normDob.isNotEmpty ? normDob : rawDob, + is4ps: is4ps, + errors: errors, + )); + } + + setState(() { + _parsedStudentRows = parsed; + _hasParsedCsv = true; + }); + } + + void _applyValidCsvRowsToQueue() { + final validRows = _parsedStudentRows.where((r) => r.isValid).toList(); + if (validRows.isEmpty) { + showErrorDialog( + context, + 'No Valid Students', + 'Please correct or add at least one valid student row before proceeding.', + ); + return; + } + + setState(() { + // Remove previously applied CSV items to avoid duplication if re-applying + _items.removeWhere((i) => i.filePath.startsWith('csv_row_')); + + for (final row in validRows) { + _items.add( + _OcrItem( + filePath: 'csv_row_${row.rowIndex}', + fileName: + '${row.lastName}, ${row.firstName} (CSV Row #${row.rowIndex})', + status: _FileStatus.done, + lrn: row.lrn, + firstName: row.firstName.toUpperCase(), + middleName: row.middleName.toUpperCase(), + lastName: row.lastName.toUpperCase(), + extension: row.extension.toUpperCase(), + sex: row.sex, + dob: row.dob, + is4ps: row.is4ps, + academicYearId: _sharedAcademicYearId, + gradeLevel: _sharedGradeLevel, + sectionId: _sharedSectionId, + trackStrand: _sharedTrackStrand, + ), + ); + } + + _step = 1; // Advance to Step 1 Review }); } @@ -438,17 +769,24 @@ class _BulkOcrImportDialogState extends ConsumerState { Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return Scaffold( - backgroundColor: isDark ? AppColors.darkPageBackground : const Color(0xFFF8F9FA), + backgroundColor: + isDark ? AppColors.darkPageBackground : const Color(0xFFF8F9FA), appBar: AppBar( backgroundColor: AppColors.primaryGreen, iconTheme: const IconThemeData(color: Colors.white), - title: const Text( - 'Bulk OCR Student Import', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), + title: const Row( + children: [ + Icon(Icons.group_add_outlined, color: Colors.white, size: 22), + SizedBox(width: 10), + Text( + 'Bulk Student Import (OCR & CSV)', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], ), ), body: SafeArea( @@ -472,26 +810,61 @@ class _BulkOcrImportDialogState extends ConsumerState { // ── stepper ─────────────────────────────────────────────────────────────── Widget _buildStepper() { final isDark = Theme.of(context).brightness == Brightness.dark; + const stepLabels = [ + '1. Select / Input Students', + '2. Review & Assign Section', + '3. Import Results', + ]; const totalSteps = 3; return Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), color: isDark ? AppColors.darkPageBackground : const Color(0xFFF8F9FA), - child: Row( - children: List.generate(totalSteps, (idx) { - final activeOrDone = _step >= idx; - return Expanded( - child: Container( - height: 4, - margin: EdgeInsets.only(right: idx < totalSteps - 1 ? 6 : 0), - decoration: BoxDecoration( - color: activeOrDone - ? AppColors.primaryGreen - : (isDark ? AppColors.darkBorder : Colors.grey.shade300), - borderRadius: BorderRadius.circular(2), - ), - ), - ); - }), + child: Column( + children: [ + Row( + children: List.generate(totalSteps, (idx) { + final activeOrDone = _step >= idx; + return Expanded( + child: Container( + height: 4, + margin: EdgeInsets.only(right: idx < totalSteps - 1 ? 6 : 0), + decoration: BoxDecoration( + color: activeOrDone + ? AppColors.primaryGreen + : (isDark ? AppColors.darkBorder : Colors.grey.shade300), + borderRadius: BorderRadius.circular(2), + ), + ), + ); + }), + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(totalSteps, (idx) { + final isCurrent = _step == idx; + final isDone = _step > idx; + return Text( + stepLabels[idx], + style: TextStyle( + fontSize: 11, + fontWeight: isCurrent || isDone + ? FontWeight.bold + : FontWeight.normal, + color: isCurrent + ? AppColors.primaryGreen + : (isDone + ? (isDark + ? AppColors.darkTextPrimary + : AppColors.textPrimary) + : (isDark + ? AppColors.darkTextMuted + : AppColors.textMuted)), + ), + ); + }), + ), + ], ), ); } @@ -510,11 +883,172 @@ class _BulkOcrImportDialogState extends ConsumerState { } } - // ── STEP 0: UPLOAD ──────────────────────────────────────────────────────── + // ── STEP 0: UPLOAD & CSV INPUT ──────────────────────────────────────────── Widget _buildUploadStep() { final isDark = Theme.of(context).brightness == Brightness.dark; final isWindows = defaultTargetPlatform == TargetPlatform.windows; + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStep0ModeSelector(isDark), + if (_activeInputTab == 0) + _buildFilesTab(isDark, isWindows) + else + _buildLiveCsvStep(isDark), + ], + ), + ); + } + + Widget _buildStep0ModeSelector(bool isDark) { + final fileCount = + _items.where((i) => !i.filePath.startsWith('csv_row_')).length; + final csvValidCount = _parsedStudentRows.where((r) => r.isValid).length; + + return Container( + margin: const EdgeInsets.only(bottom: 24), + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurface2 : const Color(0xFFF1F3F5), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isDark ? AppColors.darkBorder : Colors.grey.shade300, + ), + ), + child: Row( + children: [ + Expanded( + child: _buildModeTabButton( + index: 0, + icon: Icons.document_scanner_outlined, + label: 'Document Files & Scan (OCR)', + badgeCount: fileCount, + badgeColor: AppColors.primaryGreen, + isDark: isDark, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildModeTabButton( + index: 1, + icon: Icons.table_chart_outlined, + label: 'Live Type / Paste CSV', + badgeCount: csvValidCount, + badgeColor: Colors.blue.shade600, + isDark: isDark, + ), + ), + ], + ), + ); + } + + Widget _buildModeTabButton({ + required int index, + required IconData icon, + required String label, + required int badgeCount, + required Color badgeColor, + required bool isDark, + }) { + final isSelected = _activeInputTab == index; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => setState(() => _activeInputTab = index), + borderRadius: BorderRadius.circular(10), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14), + decoration: BoxDecoration( + color: isSelected + ? (isDark ? AppColors.darkSurfaceCard : Colors.white) + : Colors.transparent, + borderRadius: BorderRadius.circular(10), + boxShadow: isSelected + ? [ + BoxShadow( + color: Colors.black + .withValues(alpha: isDark ? 0.3 : 0.08), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ] + : null, + border: isSelected + ? Border.all( + color: isDark + ? AppColors.primaryGreen.withValues(alpha: 0.6) + : AppColors.primaryGreen, + width: 1.5, + ) + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 20, + color: isSelected + ? AppColors.primaryGreen + : (isDark + ? AppColors.darkTextSecondary + : AppColors.textSecondary), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + label, + style: TextStyle( + fontSize: 13.5, + fontWeight: + isSelected ? FontWeight.bold : FontWeight.w500, + color: isSelected + ? (isDark + ? AppColors.darkTextPrimary + : AppColors.textPrimary) + : (isDark + ? AppColors.darkTextSecondary + : AppColors.textSecondary), + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (badgeCount > 0) ...[ + const SizedBox(width: 8), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: badgeColor.withValues(alpha: 0.5), + ), + ), + child: Text( + '$badgeCount', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: badgeColor, + ), + ), + ), + ], + ], + ), + ), + ), + ); + } + + // ── Tab 0: Files Upload & OCR ── + Widget _buildFilesTab(bool isDark, bool isWindows) { Widget dropZone = GestureDetector( onTap: _isProcessing ? null : _pickFiles, child: AnimatedContainer( @@ -558,7 +1092,8 @@ class _BulkOcrImportDialogState extends ConsumerState { style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, - color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary), + color: + isDark ? AppColors.darkTextPrimary : AppColors.textPrimary), ), const SizedBox(height: 6), Text( @@ -566,7 +1101,9 @@ class _BulkOcrImportDialogState extends ConsumerState { textAlign: TextAlign.center, style: TextStyle( fontSize: 12, - color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, + color: isDark + ? AppColors.darkTextSecondary + : AppColors.textSecondary, height: 1.6), ), const SizedBox(height: 20), @@ -577,7 +1114,8 @@ class _BulkOcrImportDialogState extends ConsumerState { style: ElevatedButton.styleFrom( backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), @@ -618,38 +1156,932 @@ class _BulkOcrImportDialogState extends ConsumerState { ); } - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - dropZone, - if (_items.isNotEmpty) ...[ - const SizedBox(height: 20), - Row(children: [ - Text('Queued Files (${_items.length})', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary)), - const Spacer(), - if (!_isProcessing) - TextButton.icon( - onPressed: () => setState(() => _items.clear()), - icon: const Icon(Icons.clear_all, size: 16), - label: const Text('Clear All'), - style: TextButton.styleFrom( - foregroundColor: AppColors.error, iconSize: 16), - ), - ]), - const SizedBox(height: 8), - ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: _items.length, - separatorBuilder: (_, _s) => const SizedBox(height: 6), - itemBuilder: (_, i) => _buildFileQueueTile(_items[i], i), + final fileItems = + _items.where((i) => !i.filePath.startsWith('csv_row_')).toList(); + + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + dropZone, + if (fileItems.isNotEmpty) ...[ + const SizedBox(height: 20), + Row(children: [ + Text('Queued Files (${fileItems.length})', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: isDark + ? AppColors.darkTextPrimary + : AppColors.textPrimary)), + const Spacer(), + if (!_isProcessing) + TextButton.icon( + onPressed: () => setState(() { + _items.removeWhere( + (i) => !i.filePath.startsWith('csv_row_')); + }), + icon: const Icon(Icons.clear_all, size: 16), + label: const Text('Clear All'), + style: TextButton.styleFrom( + foregroundColor: AppColors.error, iconSize: 16), + ), + ]), + const SizedBox(height: 8), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: fileItems.length, + separatorBuilder: (_, unused) => const SizedBox(height: 6), + itemBuilder: (_, i) => _buildFileQueueTile(fileItems[i], i), + ), + ], + ]); + } + + // ── Tab 1: Live Type / Paste CSV ── + Widget _buildLiveCsvStep(bool isDark) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCsvFormatGuide(isDark), + const SizedBox(height: 20), + _buildCsvInputArea(isDark), + if (_hasParsedCsv) ...[ + const SizedBox(height: 24), + _buildCsvValidationTable(isDark), + ], + ], + ); + } + + Widget _buildCsvFormatGuide(bool isDark) { + final cardBg = isDark ? AppColors.darkSurfaceCard : Colors.white; + final borderColor = isDark ? AppColors.darkBorder : Colors.grey.shade300; + + return Container( + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04), + blurRadius: 8, + offset: const Offset(0, 2), ), ], - ]), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with icon and action buttons + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isDark + ? AppColors.primaryGreen.withValues(alpha: 0.12) + : const Color(0xFFF0FAF4), + borderRadius: + const BorderRadius.vertical(top: Radius.circular(13)), + border: Border( + bottom: BorderSide( + color: isDark + ? AppColors.darkBorder + : AppColors.primaryGreen.withValues(alpha: 0.2), + ), + ), + ), + child: Row( + children: [ + const Icon( + Icons.menu_book_outlined, + size: 20, + color: AppColors.primaryGreen, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'CSV Format Reference & Sample Template', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: isDark + ? AppColors.darkTextPrimary + : AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + '8 Columns: LRN, First Name, Middle Name, Last Name, Extension, Sex, Birth Date, 4Ps', + style: TextStyle( + fontSize: 11.5, + color: isDark + ? AppColors.darkTextSecondary + : AppColors.textSecondary, + ), + ), + ], + ), + ), + Wrap( + spacing: 8, + children: [ + OutlinedButton.icon( + onPressed: _loadSampleData, + icon: const Icon(Icons.flash_on_rounded, size: 15), + label: const Text('Fill Example', + style: TextStyle(fontSize: 12)), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primaryGreen, + side: BorderSide( + color: AppColors.primaryGreen.withValues(alpha: 0.5), + ), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + visualDensity: VisualDensity.compact, + ), + ), + FilledButton.icon( + onPressed: _copyTemplate, + icon: const Icon(Icons.copy_rounded, size: 15), + label: const Text('Copy Template', + style: TextStyle(fontSize: 12)), + style: FilledButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + ], + ), + ), + + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Column definitions badges + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildColumnBadge( + '1. LRN', '12 Digits (Req.)', true, isDark), + _buildColumnBadge( + '2. First Name', 'Required', true, isDark), + _buildColumnBadge( + '3. Middle Name', 'Optional', false, isDark), + _buildColumnBadge( + '4. Last Name', 'Required', true, isDark), + _buildColumnBadge( + '5. Extension', 'Jr, Sr, III (Opt.)', false, isDark), + _buildColumnBadge( + '6. Sex', 'Male / Female (Req.)', true, isDark), + _buildColumnBadge( + '7. Birth Date', 'YYYY-MM-DD (Opt.)', false, isDark), + _buildColumnBadge('8. 4Ps', 'Yes / No (Opt.)', false, isDark), + ], + ), + const SizedBox(height: 14), + + // Sample CSV code box + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark + ? AppColors.darkSurface2 + : const Color(0xFFF8F9FA), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isDark ? AppColors.darkBorder : Colors.grey.shade300, + ), + ), + child: SelectableText( + _sampleCsvTemplate, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + height: 1.5, + color: isDark + ? Colors.green.shade300 + : const Color(0xFF2E7D32), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildColumnBadge( + String title, String subtitle, bool isRequired, bool isDark) { + final badgeColor = isRequired ? AppColors.primaryGreen : Colors.blueGrey; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: isDark ? 0.18 : 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: badgeColor.withValues(alpha: isDark ? 0.4 : 0.25), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.bold, + color: + isDark ? AppColors.darkTextPrimary : AppColors.textPrimary, + ), + ), + Text( + subtitle, + style: TextStyle( + fontSize: 10, + fontWeight: isRequired ? FontWeight.w600 : FontWeight.normal, + color: badgeColor, + ), + ), + ], + ), + ); + } + + Widget _buildCsvInputArea(bool isDark) { + final cardBg = isDark ? AppColors.darkSurfaceCard : Colors.white; + final borderColor = isDark ? AppColors.darkBorder : Colors.grey.shade300; + final lineCount = _csvTextController.text.trim().isEmpty + ? 0 + : const LineSplitter() + .convert(_csvTextController.text.trim()) + .where((l) => l.trim().isNotEmpty) + .length; + + return Container( + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.edit_note_rounded, + size: 22, + color: AppColors.primaryGreen, + ), + const SizedBox(width: 8), + Text( + 'Live Type / Paste CSV Input', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: isDark + ? AppColors.darkTextPrimary + : AppColors.textPrimary, + ), + ), + if (lineCount > 0) ...[ + const SizedBox(width: 8), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppColors.primaryGreen.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '$lineCount line${lineCount == 1 ? '' : 's'}', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: AppColors.primaryGreen, + ), + ), + ), + ], + const Spacer(), + OutlinedButton.icon( + onPressed: _pickCsvFile, + icon: const Icon(Icons.file_upload_outlined, size: 16), + label: const Text('Browse File', + style: TextStyle(fontSize: 12)), + style: OutlinedButton.styleFrom( + foregroundColor: + isDark ? Colors.white : AppColors.textPrimary, + side: BorderSide(color: borderColor), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + visualDensity: VisualDensity.compact, + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: _pasteFromClipboard, + icon: const Icon(Icons.content_paste_rounded, size: 16), + label: const Text('Paste', style: TextStyle(fontSize: 12)), + style: OutlinedButton.styleFrom( + foregroundColor: + isDark ? Colors.white : AppColors.textPrimary, + side: BorderSide(color: borderColor), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + visualDensity: VisualDensity.compact, + ), + ), + if (_csvTextController.text.isNotEmpty) ...[ + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: _clearCsvInput, + icon: const Icon(Icons.clear_rounded, size: 16), + label: const Text('Clear', style: TextStyle(fontSize: 12)), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.error, + side: BorderSide( + color: AppColors.error.withValues(alpha: 0.4), + ), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + visualDensity: VisualDensity.compact, + ), + ), + ], + ], + ), + const SizedBox(height: 12), + TextField( + controller: _csvTextController, + maxLines: 8, + minLines: 5, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12.5, + color: + isDark ? AppColors.darkTextPrimary : AppColors.textPrimary, + height: 1.45, + ), + decoration: InputDecoration( + hintText: + 'Paste or type CSV lines here...\nExample:\n308035123456, Juan, Protacio, Rizal, Jr., Male, 2008-06-19, Yes\n308035654321, Maria, Clara, Santos, , Female, 2009-11-23, No', + hintStyle: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: isDark + ? AppColors.darkTextMuted + : Colors.grey.shade400, + ), + filled: true, + fillColor: + isDark ? AppColors.darkSurface2 : const Color(0xFFFAFAFA), + contentPadding: const EdgeInsets.all(14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: borderColor), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: borderColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide( + color: AppColors.primaryGreen, + width: 1.8, + ), + ), + ), + onChanged: (_) { + if (_hasParsedCsv) { + setState(() => _hasParsedCsv = false); + } else { + setState(() {}); + } + }, + ), + const SizedBox(height: 14), + Row( + children: [ + ElevatedButton.icon( + onPressed: _csvTextController.text.trim().isEmpty + ? null + : _parseCsvInput, + icon: const Icon(Icons.table_view_rounded, size: 18), + label: const Text( + 'Parse & Preview Students', + style: TextStyle(fontWeight: FontWeight.bold), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, + disabledBackgroundColor: isDark + ? AppColors.darkSurface2 + : Colors.grey.shade300, + padding: const EdgeInsets.symmetric( + horizontal: 22, vertical: 13), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + const SizedBox(width: 14), + if (_hasParsedCsv) ...[ + const Icon(Icons.check_circle_outline, + size: 18, color: AppColors.primaryGreen), + const SizedBox(width: 6), + Text( + 'Parsed ${_parsedStudentRows.length} row(s)', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.primaryGreen, + ), + ), + ], + ], + ), + ], + ), + ), + ); + } + + Widget _buildCsvValidationTable(bool isDark) { + final validRows = _parsedStudentRows.where((r) => r.isValid).toList(); + final errorRows = _parsedStudentRows.where((r) => !r.isValid).toList(); + final validCount = validRows.length; + final errorCount = errorRows.length; + final cardBg = isDark ? AppColors.darkSurfaceCard : Colors.white; + final borderColor = isDark ? AppColors.darkBorder : Colors.grey.shade300; + + return Container( + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Table header + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: isDark + ? AppColors.darkSurface2 + : const Color(0xFFF8F9FA), + borderRadius: + const BorderRadius.vertical(top: Radius.circular(13)), + border: Border(bottom: BorderSide(color: borderColor)), + ), + child: Row( + children: [ + const Icon( + Icons.fact_check_outlined, + size: 20, + color: AppColors.primaryGreen, + ), + const SizedBox(width: 10), + Text( + 'Validation Table & Live Preview', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: isDark + ? AppColors.darkTextPrimary + : AppColors.textPrimary, + ), + ), + const SizedBox(width: 14), + // Pill badges + _buildSummaryPill( + label: 'Total: ${_parsedStudentRows.length}', + color: Colors.blueGrey, + isDark: isDark, + ), + const SizedBox(width: 6), + _buildSummaryPill( + label: 'Valid: $validCount', + color: AppColors.primaryGreen, + isDark: isDark, + ), + if (errorCount > 0) ...[ + const SizedBox(width: 6), + _buildSummaryPill( + label: 'Errors: $errorCount', + color: AppColors.error, + isDark: isDark, + ), + ], + const Spacer(), + IconButton( + onPressed: () { + setState(() { + _parsedStudentRows.clear(); + _hasParsedCsv = false; + }); + }, + icon: const Icon(Icons.close, size: 18), + tooltip: 'Dismiss Preview', + color: isDark + ? AppColors.darkTextSecondary + : AppColors.textSecondary, + ), + ], + ), + ), + + if (errorCount > 0) + Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: + AppColors.error.withValues(alpha: isDark ? 0.15 : 0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppColors.error.withValues(alpha: 0.35), + ), + ), + child: Row( + children: [ + const Icon(Icons.warning_amber_rounded, + color: AppColors.error, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + '$errorCount row(s) contain validation errors and cannot be imported until corrected. You can delete problematic rows using the trash icon.', + style: TextStyle( + fontSize: 12.5, + color: + isDark ? Colors.red.shade200 : AppColors.error, + ), + ), + ), + ], + ), + ), + + if (_parsedStudentRows.isEmpty) + Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Text( + 'No rows found in CSV input.', + style: TextStyle( + color: isDark + ? AppColors.darkTextSecondary + : AppColors.textSecondary, + ), + ), + ), + ) + else + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + headingRowHeight: 42, + dataRowMinHeight: 48, + dataRowMaxHeight: 58, + horizontalMargin: 16, + columnSpacing: 18, + headingTextStyle: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: isDark + ? AppColors.darkTextPrimary + : AppColors.textPrimary, + ), + columns: const [ + DataColumn(label: Text('#')), + DataColumn(label: Text('Status')), + DataColumn(label: Text('LRN')), + DataColumn(label: Text('Student Name')), + DataColumn(label: Text('Sex')), + DataColumn(label: Text('Birth Date')), + DataColumn(label: Text('4Ps')), + DataColumn(label: Text('Validation Details')), + DataColumn(label: Text('Action')), + ], + rows: List.generate(_parsedStudentRows.length, (i) { + final row = _parsedStudentRows[i]; + final isValid = row.isValid; + final rowColor = isValid + ? Colors.transparent + : AppColors.error.withValues(alpha: isDark ? 0.08 : 0.04); + + return DataRow( + color: WidgetStateProperty.all(rowColor), + cells: [ + DataCell( + Text( + '${row.rowIndex}', + style: TextStyle( + fontSize: 12, + color: isDark + ? AppColors.darkTextSecondary + : AppColors.textSecondary, + ), + ), + ), + DataCell( + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: (isValid + ? AppColors.primaryGreen + : AppColors.error) + .withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: (isValid + ? AppColors.primaryGreen + : AppColors.error) + .withValues(alpha: 0.4), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isValid + ? Icons.check_circle_rounded + : Icons.error_rounded, + size: 12, + color: isValid + ? AppColors.primaryGreen + : AppColors.error, + ), + const SizedBox(width: 4), + Text( + isValid ? 'VALID' : 'ERROR', + style: TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.bold, + color: isValid + ? AppColors.primaryGreen + : AppColors.error, + ), + ), + ], + ), + ), + ), + DataCell( + Text( + row.lrn.isNotEmpty ? row.lrn : '—', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + fontWeight: FontWeight.w600, + color: row.lrn.length == 12 + ? (isDark + ? AppColors.darkTextPrimary + : AppColors.textPrimary) + : AppColors.error, + ), + ), + ), + DataCell( + Text( + row.fullName, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: isDark + ? AppColors.darkTextPrimary + : AppColors.textPrimary, + ), + ), + ), + DataCell( + Text( + row.sex.isNotEmpty ? row.sex : '—', + style: TextStyle( + fontSize: 12, + color: (row.sex == 'Male' || row.sex == 'Female') + ? (isDark + ? AppColors.darkTextPrimary + : AppColors.textPrimary) + : AppColors.error, + ), + ), + ), + DataCell( + Text( + row.dob.isNotEmpty ? row.dob : '—', + style: TextStyle( + fontSize: 12, + color: isDark + ? AppColors.darkTextPrimary + : AppColors.textPrimary, + ), + ), + ), + DataCell( + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: row.is4ps + ? AppColors.primaryGreen + .withValues(alpha: 0.15) + : Colors.grey.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + row.is4ps ? 'Yes' : 'No', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: row.is4ps + ? AppColors.primaryGreen + : (isDark + ? AppColors.darkTextSecondary + : AppColors.textSecondary), + ), + ), + ), + ), + DataCell( + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: isValid + ? Row( + children: [ + const Icon(Icons.check, + size: 14, + color: AppColors.primaryGreen), + const SizedBox(width: 4), + const Text( + 'Ready for review', + style: TextStyle( + fontSize: 11.5, + color: AppColors.primaryGreen, + fontStyle: FontStyle.italic, + ), + ), + ], + ) + : Text( + row.errors.join(' • '), + style: const TextStyle( + fontSize: 11.5, + color: AppColors.error, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + ), + ), + DataCell( + IconButton( + onPressed: () { + setState(() { + _parsedStudentRows.removeAt(i); + }); + }, + icon: const Icon(Icons.delete_outline, size: 18), + color: AppColors.error, + tooltip: 'Remove row', + visualDensity: VisualDensity.compact, + ), + ), + ], + ); + }), + ), + ), + + // Bottom action banner inside table + if (validCount > 0) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: BoxDecoration( + color: AppColors.primaryGreen.withValues(alpha: 0.08), + borderRadius: + const BorderRadius.vertical(bottom: Radius.circular(13)), + border: Border( + top: BorderSide( + color: AppColors.primaryGreen.withValues(alpha: 0.25), + ), + ), + ), + child: Row( + children: [ + const Icon( + Icons.check_circle_outline_rounded, + color: AppColors.primaryGreen, + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$validCount student record(s) ready to queue', + style: TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.bold, + color: isDark + ? AppColors.darkTextPrimary + : AppColors.textPrimary, + ), + ), + Text( + 'Proceed to Step 2 to configure Academic Year, Grade, Section, and finalize import.', + style: TextStyle( + fontSize: 11.5, + color: isDark + ? AppColors.darkTextSecondary + : AppColors.textSecondary, + ), + ), + ], + ), + ), + ElevatedButton.icon( + onPressed: _applyValidCsvRowsToQueue, + icon: const Icon(Icons.arrow_forward_rounded, size: 18), + label: Text( + 'Proceed to Review ($validCount Students)', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSummaryPill({ + required String label, + required Color color, + required bool isDark, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: isDark ? 0.2 : 0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: color, + ), + ), ); } @@ -1030,7 +2462,7 @@ class _BulkOcrImportDialogState extends ConsumerState { return ListView.separated( padding: const EdgeInsets.all(16), itemCount: items.length, - separatorBuilder: (_, __) => const SizedBox(height: 10), + separatorBuilder: (_, unused) => const SizedBox(height: 10), itemBuilder: (_, i) { final item = items[i]; final rowIndex = _items.indexOf(item); @@ -1335,7 +2767,7 @@ class _BulkOcrImportDialogState extends ConsumerState { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: rows.length, - separatorBuilder: (_, _s) => const SizedBox(height: 6), + separatorBuilder: (_, unused) => const SizedBox(height: 6), itemBuilder: (_, i) { final row = rows[i] as Map; final status = row['status'] as String? ?? 'failed'; @@ -1453,29 +2885,49 @@ class _BulkOcrImportDialogState extends ConsumerState { const SizedBox(width: 12), ], - // Step 0 — Process All - if (_step == 0 && _items.isNotEmpty) - ElevatedButton.icon( - onPressed: _isProcessing ? null : _processAll, - icon: _isProcessing - ? AppButtonLoader( - size: 16, - color: isDark ? Colors.white : Colors.black, - ) - : const Icon(Icons.play_arrow_rounded, size: 20), - label: Text(_isProcessing - ? 'Processing…' - : 'Process All (${_items.length})'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryGreen, - foregroundColor: isDark ? Colors.white : Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + // Step 0 — Process All (Files) or Proceed to Review (CSV) + if (_step == 0) ...[ + if (_activeInputTab == 0 && + _items.where((i) => !i.filePath.startsWith('csv_row_')).isNotEmpty) + ElevatedButton.icon( + onPressed: _isProcessing ? null : _processAll, + icon: _isProcessing + ? AppButtonLoader( + size: 16, + color: isDark ? Colors.white : Colors.black, + ) + : const Icon(Icons.play_arrow_rounded, size: 20), + label: Text(_isProcessing + ? 'Processing…' + : 'Process All (${_items.where((i) => !i.filePath.startsWith('csv_row_')).length})'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: isDark ? Colors.white : Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), - padding: - const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), - ), + if (_activeInputTab == 1 && + _parsedStudentRows.any((r) => r.isValid)) + ElevatedButton.icon( + onPressed: _applyValidCsvRowsToQueue, + icon: const Icon(Icons.arrow_forward_rounded, size: 20), + label: Text( + 'Proceed to Review (${_parsedStudentRows.where((r) => r.isValid).length})'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: isDark ? Colors.white : Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ], // Step 1 — Back + Import if (_step == 1) ...[