|
| 1 | +import 'package:flutter/foundation.dart'; |
| 2 | +import 'package:flutter/material.dart'; |
| 3 | +import 'package:cloud_firestore/cloud_firestore.dart'; |
| 4 | +import '../../../data/models/enrollment_model.dart'; |
| 5 | +import '../../../data/models/user_model.dart'; |
| 6 | +import '../../../data/repositories/auth_repository.dart'; |
| 7 | + |
| 8 | +// Para la generación de Excel |
| 9 | +import 'package:universal_html/html.dart' as html; |
| 10 | +import 'package:excel/excel.dart'; |
| 11 | + |
| 12 | +// Definiendo un tipo para las estadísticas |
| 13 | +typedef Stats = ({int accredited, int notAccredited}); |
| 14 | + |
| 15 | +class SemesterReportViewModel extends ChangeNotifier { |
| 16 | + final FirebaseFirestore _db = FirebaseFirestore.instance; |
| 17 | + final AuthRepository _authRepo; |
| 18 | + |
| 19 | + bool _isLoading = false; |
| 20 | + String? _loadingMessage; |
| 21 | + String? _errorMessage; |
| 22 | + |
| 23 | + // --- DATOS PARA GRÁFICOS --- |
| 24 | + Map<String, Stats> _accreditedBySubject = {}; |
| 25 | + Map<String, Stats> _accreditedByAcademy = {}; |
| 26 | + |
| 27 | + bool get isLoading => _isLoading; |
| 28 | + String? get loadingMessage => _loadingMessage; |
| 29 | + String? get errorMessage => _errorMessage; |
| 30 | + |
| 31 | + Map<String, Stats> get accreditedBySubject => _accreditedBySubject; |
| 32 | + Map<String, Stats> get accreditedByAcademy => _accreditedByAcademy; |
| 33 | + |
| 34 | + SemesterReportViewModel({required AuthRepository authRepo}) : _authRepo = authRepo; |
| 35 | + |
| 36 | + void _setLoading(bool loading, [String? message]) { |
| 37 | + _isLoading = loading; |
| 38 | + _loadingMessage = message; |
| 39 | + notifyListeners(); |
| 40 | + } |
| 41 | + |
| 42 | + // --- FUNCIÓN PRIVADA PARA OBTENER DATOS --- |
| 43 | + Future<({List<UserModel> students, Map<String, List<EnrollmentModel>> enrollments})> _fetchReportData() async { |
| 44 | + _setLoading(true, "Obteniendo datos de alumnos..."); |
| 45 | + // ROL TUTORIAS: Obtiene todos los alumnos sin filtrar por academia |
| 46 | + final studentsSnapshot = await _db.collection('users') |
| 47 | + .where('role', isEqualTo: 'student') |
| 48 | + .get(); |
| 49 | + |
| 50 | + final students = studentsSnapshot.docs.map((doc) => UserModel.fromMap(doc.data(), doc.id)).toList(); |
| 51 | + final studentIds = students.map((s) => s.id).toList(); |
| 52 | + |
| 53 | + if (studentIds.isEmpty) { |
| 54 | + return (students: <UserModel>[], enrollments: <String, List<EnrollmentModel>>{}); |
| 55 | + } |
| 56 | + |
| 57 | + _setLoading(true, "Obteniendo inscripciones..."); |
| 58 | + final Map<String, List<EnrollmentModel>> allEnrollments = {}; |
| 59 | + for (int i = 0; i < studentIds.length; i += 10) { |
| 60 | + final chunk = studentIds.sublist(i, i + 10 > studentIds.length ? studentIds.length : i + 10); |
| 61 | + final enrollmentsSnapshot = await _db.collection('enrollments').where('uid', whereIn: chunk).get(); |
| 62 | + |
| 63 | + for (final doc in enrollmentsSnapshot.docs) { |
| 64 | + final enrollment = EnrollmentModel.fromMap(doc.data(), doc.id); |
| 65 | + allEnrollments.putIfAbsent(enrollment.uid, () => []).add(enrollment); |
| 66 | + } |
| 67 | + } |
| 68 | + return (students: students, enrollments: allEnrollments); |
| 69 | + } |
| 70 | + |
| 71 | + // --- LÓGICA PARA GRÁFICOS --- |
| 72 | + Future<void> processChartData() async { |
| 73 | + _setLoading(true, "Procesando datos para gráficos..."); |
| 74 | + _errorMessage = null; |
| 75 | + |
| 76 | + try { |
| 77 | + final data = await _fetchReportData(); |
| 78 | + if (data.students.isEmpty) { |
| 79 | + _accreditedBySubject = {}; |
| 80 | + _accreditedByAcademy = {}; |
| 81 | + notifyListeners(); |
| 82 | + return; |
| 83 | + } |
| 84 | + |
| 85 | + final tempBySubject = <String, Stats>{}; |
| 86 | + final tempByAcademy = <String, Stats>{}; |
| 87 | + |
| 88 | + for (final student in data.students) { |
| 89 | + final studentEnrollments = data.enrollments[student.id] ?? []; |
| 90 | + for (final enrollment in studentEnrollments) { |
| 91 | + if (enrollment.status == 'ACREDITADO' || enrollment.status == 'NO_ACREDITADO') { |
| 92 | + // Conteo por materia |
| 93 | + final subjectStat = tempBySubject.putIfAbsent(enrollment.subject, () => (accredited: 0, notAccredited: 0)); |
| 94 | + if (enrollment.status == 'ACREDITADO') { |
| 95 | + tempBySubject[enrollment.subject] = (accredited: subjectStat.accredited + 1, notAccredited: subjectStat.notAccredited); |
| 96 | + } else { |
| 97 | + tempBySubject[enrollment.subject] = (accredited: subjectStat.accredited, notAccredited: subjectStat.notAccredited + 1); |
| 98 | + } |
| 99 | + |
| 100 | + // Conteo por academia |
| 101 | + final academyStat = tempByAcademy.putIfAbsent(enrollment.academy, () => (accredited: 0, notAccredited: 0)); |
| 102 | + if (enrollment.status == 'ACREDITADO') { |
| 103 | + tempByAcademy[enrollment.academy] = (accredited: academyStat.accredited + 1, notAccredited: academyStat.notAccredited); |
| 104 | + } else { |
| 105 | + tempByAcademy[enrollment.academy] = (accredited: academyStat.accredited, notAccredited: academyStat.notAccredited + 1); |
| 106 | + } |
| 107 | + } |
| 108 | + } |
| 109 | + } |
| 110 | + _accreditedBySubject = tempBySubject; |
| 111 | + _accreditedByAcademy = tempByAcademy; |
| 112 | + |
| 113 | + } catch (e) { |
| 114 | + _errorMessage = e.toString().replaceAll("Exception: ", ""); |
| 115 | + } finally { |
| 116 | + _setLoading(false); |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + |
| 121 | + // --- LÓGICA PARA EXCEL --- |
| 122 | + Future<void> generateExcelReport() async { |
| 123 | + _setLoading(true, "Generando reporte..."); |
| 124 | + _errorMessage = null; |
| 125 | + |
| 126 | + try { |
| 127 | + final data = await _fetchReportData(); |
| 128 | + if (data.students.isEmpty) { |
| 129 | + throw Exception("No hay alumnos para reportar."); |
| 130 | + } |
| 131 | + |
| 132 | + _setLoading(true, "Creando archivo Excel..."); |
| 133 | + final excel = Excel.createExcel(); |
| 134 | + final Sheet sheet = excel[excel.getDefaultSheet()!]; |
| 135 | + |
| 136 | + final headers = ['Boleta', 'Nombre del Alumno', 'Materia', 'Estatus', 'Calificación']; |
| 137 | + sheet.appendRow(headers.map((h) => TextCellValue(h)).toList()); |
| 138 | + |
| 139 | + for (final student in data.students) { |
| 140 | + final studentEnrollments = data.enrollments[student.id] ?? []; |
| 141 | + if (studentEnrollments.isEmpty) { |
| 142 | + sheet.appendRow([TextCellValue(student.boleta), TextCellValue(student.name), TextCellValue('-'), TextCellValue('Sin materias'), TextCellValue('-')]); |
| 143 | + } else { |
| 144 | + for (final enrollment in studentEnrollments) { |
| 145 | + sheet.appendRow([ |
| 146 | + TextCellValue(student.boleta), |
| 147 | + TextCellValue(student.name), |
| 148 | + TextCellValue(enrollment.subject), |
| 149 | + TextCellValue(enrollment.status), |
| 150 | + TextCellValue(enrollment.finalGrade?.toString() ?? 'N/A'), |
| 151 | + ]); |
| 152 | + } |
| 153 | + } |
| 154 | + } |
| 155 | + |
| 156 | + final fileBytes = excel.save(); |
| 157 | + if (fileBytes != null && kIsWeb) { |
| 158 | + final blob = html.Blob([fileBytes], 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); |
| 159 | + final url = html.Url.createObjectUrlFromBlob(blob); |
| 160 | + final anchor = html.AnchorElement(href: url)..setAttribute("download", "Reporte_Fin_Semestre.xlsx")..click(); |
| 161 | + html.Url.revokeObjectUrl(url); |
| 162 | + } else if (!kIsWeb) { |
| 163 | + throw Exception("La descarga de archivos solo está soportada en la versión web."); |
| 164 | + } |
| 165 | + |
| 166 | + } catch (e) { |
| 167 | + _errorMessage = e.toString().replaceAll("Exception: ", ""); |
| 168 | + } finally { |
| 169 | + _setLoading(false); |
| 170 | + } |
| 171 | + } |
| 172 | + |
| 173 | + // --- LÓGICA DE LIMPIEZA (CORREGIDA) --- |
| 174 | + Future<String?> deleteAllStudents() async { |
| 175 | + _setLoading(true, "Eliminando TODOS los alumnos..."); |
| 176 | + _errorMessage = null; |
| 177 | + |
| 178 | + try { |
| 179 | + final batch = _db.batch(); |
| 180 | + final snapshot = await _db.collection('users').where('role', isEqualTo: 'student').get(); |
| 181 | + |
| 182 | + if (snapshot.docs.isEmpty) { |
| 183 | + return "No hay alumnos para eliminar."; |
| 184 | + } |
| 185 | + |
| 186 | + for (final doc in snapshot.docs) { |
| 187 | + batch.delete(doc.reference); |
| 188 | + } |
| 189 | + |
| 190 | + await batch.commit(); |
| 191 | + |
| 192 | + return "${snapshot.docs.length} alumnos han sido eliminados."; |
| 193 | + } catch (e) { |
| 194 | + _errorMessage = "Error al eliminar alumnos: $e"; |
| 195 | + return null; |
| 196 | + } finally { |
| 197 | + _setLoading(false); |
| 198 | + } |
| 199 | + } |
| 200 | +} |
0 commit comments