// πŸ‘₯ κ΅μ‚¬μš© 학생 계정 관리 ν™”λ©΄μ˜ κΈ°λŠ₯(μ„œλ²„ 톡신/μƒνƒœ/μ—‘μ…€ 일괄등둝) λ‹΄λ‹Ή 컨트둀러. import 'dart:convert'; import 'package:archive/archive.dart'; import 'package:excel/excel.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../config.dart'; /// πŸ“„ μ—‘μ…€ ν•œ μ€„μ—μ„œ 뽑아낸 학생 정보 (아직 μ„œλ²„μ— 보내기 μ „, 미리보기용). class BulkStudentRow { final String studentId; final String name; final int? grade; const BulkStudentRow({ required this.studentId, required this.name, this.grade, }); } /// πŸ“Š 일괄 등둝 κ²°κ³Ό. [failures]λŠ” "ν•™λ²ˆ 이름: μ‹€νŒ¨μ‚¬μœ " ν˜•νƒœμ˜ λ¬Έμžμ—΄ λͺ©λ‘. class BulkImportResult { final int successCount; final List failures; const BulkImportResult({required this.successCount, required this.failures}); } class TeacherStudentManagementController extends ChangeNotifier { bool _isWorking = false; bool get isWorking => _isWorking; int _bulkTotal = 0; int _bulkDone = 0; int get bulkTotal => _bulkTotal; int get bulkDone => _bulkDone; List _students = []; bool _isLoadingStudents = true; String _searchQuery = ''; int? _gradeFilter; // null = 전체 List get students => _students; bool get isLoadingStudents => _isLoadingStudents; String get searchQuery => _searchQuery; int? get gradeFilter => _gradeFilter; /// πŸ” ν•™λ²ˆ 검색 + ν•™λ…„ ν•„ν„°λ₯Ό μ μš©ν•˜κ³ , ν•™λ…„ μ˜€λ¦„μ°¨μˆœ(1β†’2β†’3, 미배정은 맨 λ’€)으둜 /// μ •λ ¬ν•œ λͺ©λ‘. ν™”λ©΄μ—λŠ” 이 λͺ©λ‘μ„ κ·Έλ¦°λ‹€. List get filteredStudents { final query = _searchQuery.trim(); final filtered = _students.where((s) { if (_gradeFilter != null && s['grade'] != _gradeFilter) return false; if (query.isEmpty) return true; return s['id'].toString().contains(query); }).toList(); filtered.sort((a, b) { final ga = a['grade'] as int?; final gb = b['grade'] as int?; if (ga == null && gb == null) return 0; if (ga == null) return 1; if (gb == null) return -1; return ga.compareTo(gb); }); return filtered; } void setSearchQuery(String query) { _searchQuery = query; notifyListeners(); } void setGradeFilter(int? grade) { _gradeFilter = grade; notifyListeners(); } Future init() => fetchStudents(); /// 🌐 전체 학생 λͺ©λ‘ 뢈러였기 (ν•™λ²ˆ/이름/ν•™λ…„/κΈ°κΈ°λ“±λ‘μƒνƒœ). Future fetchStudents() async { _isLoadingStudents = true; notifyListeners(); try { final response = await http.get(Uri.parse('$baseUrl/api/users')); if (response.statusCode == 200) { final data = jsonDecode(utf8.decode(response.bodyBytes)); _students = data['users'] ?? []; } } catch (_) { // λͺ©λ‘ μƒˆλ‘œκ³ μΉ¨ μ‹€νŒ¨λŠ” 쑰용히 λ¬΄μ‹œ β€” ν™”λ©΄μ—” λ§ˆμ§€λ§‰μœΌλ‘œ 뢈러온 λͺ©λ‘μ΄ λ‚¨λŠ”λ‹€. } finally { _isLoadingStudents = false; notifyListeners(); } } /// 🌐 ν•™μƒμ˜ λ“±λ‘λœ κΈ°κΈ°(슀마트폰) UUID + λΉ„λ°€λ²ˆν˜Έ(1234)λ₯Ό μ΄ˆκΈ°ν™”ν•œλ‹€. /// (학생이 폰을 바꿨을 λ•Œ λ‹€μ‹œ 등둝할 수 있게 ν•΄μ£ΌλŠ” μš©λ„) Future<(bool success, String message)> resetDevice( String studentId, String studentName, ) async { _isWorking = true; notifyListeners(); try { final response = await http.post( Uri.parse('$baseUrl/api/users/reset'), headers: {"Content-Type": "application/json"}, body: jsonEncode({"studentId": studentId}), ); if (response.statusCode == 200) { await fetchStudents(); return (true, '$studentName 학생 κΈ°κΈ° μ΄ˆκΈ°ν™” μ™„λ£Œ! (λΉ„λ°€λ²ˆν˜Έλ„ 1234둜 μ΄ˆκΈ°ν™”λ¨)'); } return (false, 'μ΄ˆκΈ°ν™” 톡신 μ‹€νŒ¨'); } catch (e) { return (false, 'μ΄ˆκΈ°ν™” 톡신 μ‹€νŒ¨: $e'); } finally { _isWorking = false; notifyListeners(); } } /// πŸ“„ μ—‘μ…€ 파일 λ°”μ΄νŠΈλ₯Ό νŒŒμ‹±ν•œλ‹€. 1행은 λ¨Έλ¦¬κΈ€λ‘œ 보고 κ±΄λ„ˆλ›°λ©°, /// Aμ—΄=ν•™λ²ˆ, Bμ—΄=이름, Cμ—΄=ν•™λ…„(선택, 숫자) μˆœμ„œλ₯Ό κΈ°λŒ€ν•œλ‹€. /// ν•™λ²ˆμ΄λ‚˜ 이름이 λΉ„μ–΄μžˆλŠ” 쀄은 쑰용히 λ¬΄μ‹œν•œλ‹€. List parseExcelBytes(Uint8List bytes) { final excel = Excel.decodeBytes(_sanitizeXlsxBytes(bytes)); if (excel.tables.isEmpty) return []; final sheet = excel.tables.values.first; final rows = []; for (final row in sheet.rows.skip(1)) { final studentId = _cellText(row.isNotEmpty ? row[0]?.value : null); final name = _cellText(row.length > 1 ? row[1]?.value : null); if (studentId == null || name == null) continue; final grade = _cellToGrade(row.length > 2 ? row[2]?.value : null); rows.add(BulkStudentRow(studentId: studentId, name: name, grade: grade)); } return rows; } /// 🩹 [μ—‘μ…€ νŒŒμ‹± 버그 우회] 일뢀 μ—‘μ…€ 파일(κ΅¬κΈ€μ‹œνŠΈ/WPS λ“±μ—μ„œ 내보낸 파일 포함)은 /// styles.xml에 μ»€μŠ€ν…€ μˆ«μžμ„œμ‹ idλ₯Ό 164 미만(예: 0)으둜 잘λͺ» μ μ–΄λ‘λŠ”λ°, excel /// νŒ¨ν‚€μ§€κ°€ 이걸 μ—„κ²©ν•˜κ²Œ κ±ΈλŸ¬λ‚΄λ©° μ˜ˆμ™Έλ₯Ό λ˜μ Έμ„œ 파일 전체λ₯Ό λͺ» 읽게 λ§Œλ“ λ‹€. /// μš°λ¦¬λŠ” μ…€ κ°’(ν•™λ²ˆ/이름/ν•™λ…„)만 ν•„μš”ν•˜κ³  μ„œμ‹μ€ ν•„μš” μ—†μœΌλ‹ˆ, zip μ•ˆμ˜ styles.xml만 /// μ—΄μ–΄μ„œ κ·Έ 잘λͺ»λœ μ»€μŠ€ν…€ μ„œμ‹ 선언을 μ§€μš°κ³  λ‹€μ‹œ μ••μΆ•ν•΄μ„œ λ„˜κΈ΄λ‹€. Uint8List _sanitizeXlsxBytes(Uint8List bytes) { try { final zip = ZipDecoder().decodeBytes(bytes); final stylesFile = zip.files.cast().firstWhere( (f) => f?.name == 'xl/styles.xml', orElse: () => null, ); if (stylesFile == null) return bytes; final xml = utf8.decode(stylesFile.content as List); final fixedXml = _stripInvalidCustomNumFmts(xml); if (fixedXml == xml) return bytes; // κ³ μΉ  게 μ—†μ—ˆμœΌλ©΄ 원본 κ·ΈλŒ€λ‘œ. final newZip = Archive(); for (final file in zip.files) { if (!file.isFile) continue; if (file.name == 'xl/styles.xml') { final content = utf8.encode(fixedXml); newZip.addFile(ArchiveFile('xl/styles.xml', content.length, content)); } else { newZip.addFile(ArchiveFile(file.name, file.size, file.content)); } } final rezipped = ZipEncoder().encode(newZip); return rezipped != null ? Uint8List.fromList(rezipped) : bytes; } catch (_) { return bytes; // μ†λŒ€λ‹€ μ‹€νŒ¨ν•˜λ©΄ 원본 κ·ΈλŒ€λ‘œ μ‹œλ„ (κΈ°μ‘΄ λ™μž‘ μœ μ§€). } } /// styles.xml λ¬Έμžμ—΄μ—μ„œ `` νƒœκ·Έ 쀑 N이 164 미만인 /// (규격 μœ„λ°˜μΈ) κ²ƒλ§Œ ν†΅μ§Έλ‘œ μ œκ±°ν•œλ‹€. κ·Έ μ„œμ‹μ„ μ°Έμ‘°ν•˜λ˜ 셀은 κΈ°λ³Έ μ„œμ‹μœΌλ‘œ 처리될 /// 뿐이라 κ°’ μ½κΈ°μ—λŠ” 영ν–₯이 μ—†λ‹€. String _stripInvalidCustomNumFmts(String xml) { final buffer = StringBuffer(); int i = 0; while (true) { final tagStart = xml.indexOf('', tagStart); if (tagEnd == -1) { buffer.write(xml.substring(i)); break; } buffer.write(xml.substring(i, tagStart)); final tag = xml.substring(tagStart, tagEnd + 2); final idMatch = RegExp(r'numFmtId="(\d+)"').firstMatch(tag); final id = idMatch != null ? int.tryParse(idMatch.group(1)!) : null; if (id == null || id >= 164) { buffer.write(tag); // 정상 λ²”μœ„μ˜ μ»€μŠ€ν…€ μ„œμ‹μ€ κ·ΈλŒ€λ‘œ λ‘”λ‹€. } i = tagEnd + 2; } return buffer.toString(); } String? _cellText(CellValue? value) { if (value == null) return null; String text; if (value is IntCellValue) { text = value.value.toString(); } else if (value is DoubleCellValue) { text = value.value == value.value.roundToDouble() ? value.value.toInt().toString() : value.value.toString(); } else { text = value.toString().trim(); } return text.isEmpty ? null : text; } int? _cellToGrade(CellValue? value) { if (value == null) return null; if (value is IntCellValue) return value.value; if (value is DoubleCellValue) return value.value.round(); return int.tryParse(value.toString().trim()); } /// βž• μ—‘μ…€μ—μ„œ 뽑아낸 학생 λͺ©λ‘μ„ ν•œ λͺ…μ”© μˆœμ„œλŒ€λ‘œ μ„œλ²„μ— λ“±λ‘ν•œλ‹€ (κΈ°λ³Έ λΉ„λ°€λ²ˆν˜Έ 1234). /// ν•œ λͺ… μ‹€νŒ¨ν•΄λ„ λ‚˜λ¨Έμ§€λŠ” 계속 μ§„ν–‰ν•˜κ³ , λ§ˆμ§€λ§‰μ— 성곡/μ‹€νŒ¨λ₯Ό λͺ¨μ•„μ„œ λŒλ €μ€€λ‹€. Future bulkAddStudents(List rows) async { _isWorking = true; _bulkTotal = rows.length; _bulkDone = 0; notifyListeners(); int successCount = 0; final failures = []; for (final row in rows) { final (success, message) = await _createStudentRequest( studentId: row.studentId, name: row.name, password: "1234", grade: row.grade, ); if (success) { successCount++; } else { failures.add('${row.studentId} ${row.name}: $message'); } _bulkDone++; notifyListeners(); } await fetchStudents(); _isWorking = false; notifyListeners(); return BulkImportResult(successCount: successCount, failures: failures); } /// βž• 학생 계정 μΆ”κ°€. (성곡여뢀, λ©”μ‹œμ§€)λ₯Ό λ°˜ν™˜ν•œλ‹€ β€” UIκ°€ μ„±κ³΅ν–ˆμ„ λ•Œλ§Œ μž…λ ₯칸을 λΉ„μš΄λ‹€. Future<(bool success, String message)> addStudentAccount({ required String studentId, required String name, required String password, int? grade, }) async { _isWorking = true; notifyListeners(); try { final result = await _createStudentRequest( studentId: studentId, name: name, password: password, grade: grade, ); if (result.$1) await fetchStudents(); return result; } finally { _isWorking = false; notifyListeners(); } } /// μ‹€μ œ 계정 생성 HTTP μš”μ²­. isWorking μƒνƒœλŠ” κ±΄λ“œλ¦¬μ§€ μ•ŠλŠ”λ‹€ β€” /// 단건 등둝(addStudentAccount)κ³Ό 일괄 등둝(bulkAddStudents) μ–‘μͺ½μ—μ„œ /// 각자 μ•Œλ§žμ€ μ‹œμ μ— isWorking을 κ΄€λ¦¬ν•˜κΈ° μœ„ν•΄ λΆ„λ¦¬ν–ˆλ‹€. Future<(bool success, String message)> _createStudentRequest({ required String studentId, required String name, required String password, int? grade, }) async { try { final url = Uri.parse('$baseUrl/api/users/create'); final response = await http.post( url, headers: {"Content-Type": "application/json"}, body: jsonEncode({ "studentId": studentId, "name": name, "password": password, "grade": grade, }), ); final result = jsonDecode(utf8.decode(response.bodyBytes)); if (response.statusCode == 200 && result['status'] == 'success') { return (true, '${result['message'] ?? '계정이 μƒμ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'}'); } else { return (false, '생성 μ‹€νŒ¨: ${result['message'] ?? '였λ₯˜ λ°œμƒ'}'); } } catch (e) { return (false, 'λ„€νŠΈμ›Œν¬ μ—λŸ¬: $e'); } } /// 🏫 이미 λ§Œλ“€μ–΄μ§„ 학생 κ³„μ •μ˜ 학년을 μ§€μ •/λ³€κ²½ν•œλ‹€. gradeκ°€ null이면 "λ―Έλ°°μ •"으둜 λ˜λŒλ¦°λ‹€. Future<(bool success, String message)> updateStudentGrade({ required String studentId, int? grade, }) async { _isWorking = true; notifyListeners(); try { final url = Uri.parse('$baseUrl/api/users/update-grade'); final response = await http.post( url, headers: {"Content-Type": "application/json"}, body: jsonEncode({"studentId": studentId, "grade": grade}), ); final result = jsonDecode(utf8.decode(response.bodyBytes)); if (response.statusCode == 200 && result['status'] == 'success') { await fetchStudents(); return (true, '${result['message'] ?? '학년이 μ§€μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'}'); } else { return (false, 'μ§€μ • μ‹€νŒ¨: ${result['message'] ?? '였λ₯˜ λ°œμƒ'}'); } } catch (e) { return (false, 'λ„€νŠΈμ›Œν¬ μ—λŸ¬: $e'); } finally { _isWorking = false; notifyListeners(); } } /// ❌ 학생 계정 μ‚­μ œ. Future<(bool success, String message)> deleteStudentAccount( String studentId, ) async { _isWorking = true; notifyListeners(); final url = Uri.parse('$baseUrl/api/users/delete'); try { final response = await http.delete( url, headers: {"Content-Type": "application/json"}, body: jsonEncode({"studentId": studentId}), ); if (response.statusCode == 200) { final resData = jsonDecode(utf8.decode(response.bodyBytes)); await fetchStudents(); return (true, '${resData['message']}'); } else { return (false, 'μ‚­μ œ μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.'); } } catch (e) { return (false, 'μ„œλ²„ μ—λŸ¬ λ°œμƒ'); } finally { _isWorking = false; notifyListeners(); } } }