From 911c03d61e0d3b6bc39b1db89b6bf0b243e1ebe0 Mon Sep 17 00:00:00 2001 From: ssbg04 Date: Sat, 8 Aug 2026 01:45:28 +0800 Subject: [PATCH] feat(frontend): multi/single OCR enrollment review and upload for add student screen --- .../lib/ui/providers/student_provider.dart | 5 +- .../students/widgets/add_student_modal.dart | 150 +++++++++++++++--- .../ocr_enrollment_validation_modal.dart | 31 ++-- 3 files changed, 150 insertions(+), 36 deletions(-) diff --git a/frontend/lib/ui/providers/student_provider.dart b/frontend/lib/ui/providers/student_provider.dart index d00a6a6..874cef3 100644 --- a/frontend/lib/ui/providers/student_provider.dart +++ b/frontend/lib/ui/providers/student_provider.dart @@ -219,7 +219,7 @@ class StudentMutationNotifier extends AsyncNotifier { @override FutureOr build() {} - Future createStudent({ + Future createStudent({ required String lrn, required String firstName, String? middleName, @@ -236,7 +236,7 @@ class StudentMutationNotifier extends AsyncNotifier { state = const AsyncLoading(); try { final repo = ref.read(studentRepositoryProvider); - await repo.createStudent( + final id = await repo.createStudent( lrn: lrn, firstName: firstName, middleName: middleName, @@ -253,6 +253,7 @@ class StudentMutationNotifier extends AsyncNotifier { state = const AsyncData(null); // Invalidate to refresh the list ref.invalidate(studentPageProvider); + return id; } catch (e, st) { state = AsyncError(e, st); rethrow; diff --git a/frontend/lib/ui/screens/students/widgets/add_student_modal.dart b/frontend/lib/ui/screens/students/widgets/add_student_modal.dart index 5739588..ad7ba31 100644 --- a/frontend/lib/ui/screens/students/widgets/add_student_modal.dart +++ b/frontend/lib/ui/screens/students/widgets/add_student_modal.dart @@ -12,6 +12,7 @@ import '../../../shared/buttons/primary_button.dart'; import '../../../providers/setup_provider.dart'; import '../../../providers/ocr_provider.dart'; import '../../../providers/student_provider.dart'; +import '../../../providers/document_provider.dart'; import '../../documents/widgets/document_preview_modal.dart'; import '../../../shared/inputs/document_source_picker.dart'; import 'ocr_enrollment_validation_modal.dart'; @@ -46,11 +47,12 @@ class _AddStudentModalState extends ConsumerState { int? _selectedSectionId; String? _trackStrand; - /// Enrollment accepted from the OCR review dialog; auto-fills the - /// enrollment step until the student is saved. - OcrEnrollmentPrefill? _ocrSavedEnrollment; + /// Enrollments accepted from the OCR review dialog; auto-fill the + /// enrollment step and are created with the student on save. + final List _ocrSavedEnrollments = []; File? _ocrScannedFile; + String _ocrScannedFileName = ''; String? _selectedOcrDocType; static const _extSuggestions = [ @@ -260,6 +262,7 @@ class _AddStudentModalState extends ConsumerState { setState(() { _errorMessage = null; _ocrScannedFile = file; + _ocrScannedFileName = fileName; }); String? docType = _detectDocType(fileName); @@ -318,9 +321,9 @@ class _AddStudentModalState extends ConsumerState { } /// For SF9/SF10 scans with a Grade 7-12 level, let the user review and - /// edit the detected enrollment (year level, grade level, section). - /// On accept the enrollment is saved into [_ocrSavedEnrollment] and the - /// enrollment step auto-fills with it. + /// edit the detected enrollment(s) (academic year, grade level, section). + /// Accepted enrollments are appended to [_ocrSavedEnrollments] and the + /// enrollment step auto-fills with the last accepted one. Future _offerOcrEnrollment( OcrResultModel ocrResult, String docType, @@ -330,21 +333,27 @@ class _AddStudentModalState extends ConsumerState { if (gradeNum == null || gradeNum < 7 || gradeNum > 12) return; if (!mounted) return; - final prefill = await OcrEnrollmentValidationModal.showForAddStudent( + final accepted = await OcrEnrollmentValidationModal.showForAddStudent( context, - record: OcrEnrollmentRecord( - gradeLevel: gradeNum.toString(), - section: ocrResult.section, - schoolYear: ocrResult.schoolYear, - ), + records: [ + OcrEnrollmentRecord( + gradeLevel: gradeNum.toString(), + section: ocrResult.section, + schoolYear: ocrResult.schoolYear, + ), + ], ); - if (!mounted || prefill == null) return; + if (!mounted || accepted.isEmpty) return; setState(() { - _ocrSavedEnrollment = prefill; - _selectedAcademicYearId = prefill.academicYearId; - _selectedGradeLevel = prefill.gradeLevel; - _selectedSectionId = prefill.sectionId; + _ocrSavedEnrollments.addAll(accepted); + final last = accepted.last; + _selectedAcademicYearId = last.academicYearId; + _selectedGradeLevel = last.gradeLevel; + _selectedSectionId = last.sectionId; + _trackStrand = ocrResult.trackStrand.trim().isEmpty + ? null + : ocrResult.trackStrand.trim(); }); } @@ -415,7 +424,7 @@ class _AddStudentModalState extends ConsumerState { setState(() => _isLoading = true); try { final notifier = ref.read(studentMutationProvider.notifier); - await notifier.createStudent( + final studentId = await notifier.createStudent( lrn: _lrnController.text.trim(), firstName: _firstNameController.text.trim(), middleName: _middleNameController.text.trim().isEmpty @@ -433,8 +442,65 @@ class _AddStudentModalState extends ConsumerState { trackStrand: _trackStrand, is4ps: _is4ps, ); + + // Additional enrollments accepted from OCR scans (the primary one + // was already created with the student). + for (final e in _ocrSavedEnrollments) { + if (e.academicYearId == _selectedAcademicYearId && + e.gradeLevel == _selectedGradeLevel && + e.sectionId == _selectedSectionId) { + continue; + } + await notifier.addEnrollment( + studentId: studentId, + academicYearId: e.academicYearId, + gradeLevel: e.gradeLevel, + sectionId: e.sectionId!, + ); + } + + // After the student's directory is created, upload the scanned + // SF9/SF10 document into it. + String? uploadError; + if (_ocrScannedFile != null) { + try { + final bytes = await _ocrScannedFile!.readAsBytes(); + await ref.read(documentRepositoryProvider).uploadDocumentBytes( + studentId: studentId, + documentType: _selectedOcrDocType ?? 'SF9', + fileName: _ocrScannedFileName.isEmpty + ? _ocrScannedFile!.path.split(RegExp(r'[\\/]')).last + : _ocrScannedFileName, + bytes: bytes, + ); + } catch (e) { + final raw = e.toString(); + uploadError = raw.startsWith('Exception: ') ? raw.substring(11) : raw; + } + } + if (!mounted) return; ref.invalidate(studentPageProvider); + if (uploadError != null) { + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Student Created - Upload Failed'), + content: Text( + 'The student was created successfully, but the scanned ' + 'document could not be uploaded to their folder.\n\n' + '$uploadError', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + if (!mounted) return; Navigator.of(context).pop(true); } catch (e) { if (!mounted) return; @@ -786,7 +852,7 @@ class _AddStudentModalState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (_ocrSavedEnrollment != null) ...[ + if (_ocrSavedEnrollments.isNotEmpty) ...[ Container( padding: const EdgeInsets.all(AppSizes.p12), decoration: BoxDecoration( @@ -807,17 +873,53 @@ class _AddStudentModalState extends ConsumerState { const SizedBox(width: 8), Expanded( child: Text( - 'Enrollment saved from OCR scan: Grade ' - '${_ocrSavedEnrollment!.gradeLevel} · ' - '${_ocrSavedEnrollment!.sectionName} · ' - 'SY ${_ocrSavedEnrollment!.schoolYear} — ' - 'verify before saving the student.', + _ocrSavedEnrollments.length == 1 + ? 'Enrollment saved from OCR scan: Grade ' + '${_ocrSavedEnrollments.first.gradeLevel} · ' + '${_ocrSavedEnrollments.first.sectionName} · ' + 'SY ${_ocrSavedEnrollments.first.schoolYear} — ' + 'verify before saving the student.' + : '${_ocrSavedEnrollments.length} enrollments saved ' + 'from OCR scans — verify before saving the ' + 'student.', style: const TextStyle(fontSize: 13), ), ), ], ), ), + if (_ocrSavedEnrollments.length > 1) ...[ + const SizedBox(height: AppSizes.p8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final e in _ocrSavedEnrollments) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: AppColors.primaryGreen.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppColors.primaryGreen.withValues(alpha: 0.25), + ), + ), + child: Text( + 'Grade ${e.gradeLevel} · ${e.sectionName} · ' + 'SY ${e.schoolYear}', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.primaryGreen, + ), + ), + ), + ], + ), + ], const SizedBox(height: AppSizes.p16), ], const SectionLabel(label: 'ENROLLMENT DETAILS'), diff --git a/frontend/lib/ui/screens/students/widgets/ocr_enrollment_validation_modal.dart b/frontend/lib/ui/screens/students/widgets/ocr_enrollment_validation_modal.dart index 086dfec..357f628 100644 --- a/frontend/lib/ui/screens/students/widgets/ocr_enrollment_validation_modal.dart +++ b/frontend/lib/ui/screens/students/widgets/ocr_enrollment_validation_modal.dart @@ -70,19 +70,21 @@ class OcrEnrollmentValidationModal extends ConsumerStatefulWidget { } /// Add-student mode: no API writes. Returns the accepted, edited - /// enrollment, or null when the user rejects the record. - static Future showForAddStudent( + /// enrollments, or an empty list when the user rejects/closes without + /// accepting anything. + static Future> showForAddStudent( BuildContext context, { - required OcrEnrollmentRecord record, - }) { - return showDialog( + required List records, + }) async { + final result = await showDialog>( context: context, barrierDismissible: true, builder: (_) => OcrEnrollmentValidationModal._( - records: [record], + records: records, mode: _ValidationMode.addStudent, ), ); + return result ?? const []; } @override @@ -94,6 +96,7 @@ class _OcrEnrollmentValidationModalState extends ConsumerState { late List<_Entry> _entries; int _acceptedCount = 0; + final List _acceptedPrefills = []; int? _acceptingIndex; bool get _isEditMode => widget._mode == _ValidationMode.editStudent; @@ -192,7 +195,10 @@ class _OcrEnrollmentValidationModalState entry.record.section.isEmpty ? 'Section' : entry.record.section, schoolYear: year?.yearRange ?? entry.record.schoolYear, ); - Navigator.of(context).pop(prefill); + setState(() { + _acceptedPrefills.add(prefill); + _entries.removeAt(index); + }); return; } @@ -224,7 +230,11 @@ class _OcrEnrollmentValidationModalState } void _close() { - Navigator.of(context).pop(_isEditMode ? _acceptedCount : null); + Navigator.of(context).pop( + _isEditMode + ? _acceptedCount + : List.from(_acceptedPrefills), + ); } InputDecoration _deco(String label) { @@ -247,7 +257,8 @@ class _OcrEnrollmentValidationModalState final sectionsAsync = ref.watch(sectionsListProvider); final years = yearsAsync.asData?.value ?? []; final allSections = sectionsAsync.asData?.value ?? []; - final initialCount = _entries.length + _acceptedCount; + final initialCount = + _entries.length + (_isEditMode ? _acceptedCount : _acceptedPrefills.length); return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), @@ -320,7 +331,7 @@ class _OcrEnrollmentValidationModalState Text( _isEditMode ? '$_acceptedCount of $initialCount accepted' - : 'Accept to add this enrollment', + : '${_acceptedPrefills.length} of $initialCount accepted', style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500,