Files
school-attendance/lib/function/teacher_student_management_controller.dart
T
sihooandClaude Sonnet 5 94ba3efff7 선생님/마스터 계정의 학생 계정 관리 화면을 하나로 통합
- 두 화면이 서로 다른 기능만 갖고 있던 문제 해결: 선생님 화면(엑셀 일괄등록,
  학년 지정, 커스텀 초기비번)에 마스터 화면에만 있던 실시간 학생 목록 조회 +
  기기 리셋(학생이 폰 바꿨을 때 재등록용) 기능을 합침
- 학번을 직접 타이핑하던 블라인드 학년변경/삭제 다이얼로그를 목록의
  행별 액션(학년 칩, 기기 리셋 버튼, 삭제 버튼)으로 교체
- 계정 생성/삭제/학년변경 성공 시 목록 자동 새로고침
- 마스터(2061) 계정의 "학생 계정 관리" 카드도 이 통합 화면으로 연결하고,
  중복되던 student_management_screen.dart / student_management_controller.dart 삭제

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 01:23:46 +09:00

281 lines
9.1 KiB
Dart

// 👥 교사용 학생 계정 관리 화면의 기능(서버 통신/상태/엑셀 일괄등록) 담당 컨트롤러.
import 'dart:convert';
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<String> 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<dynamic> _students = [];
bool _isLoadingStudents = true;
List<dynamic> get students => _students;
bool get isLoadingStudents => _isLoadingStudents;
Future<void> init() => fetchStudents();
/// 🌐 전체 학생 목록 불러오기 (학번/이름/학년/기기등록상태).
Future<void> 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<BulkStudentRow> parseExcelBytes(Uint8List bytes) {
final excel = Excel.decodeBytes(bytes);
if (excel.tables.isEmpty) return [];
final sheet = excel.tables.values.first;
final rows = <BulkStudentRow>[];
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;
}
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<BulkImportResult> bulkAddStudents(List<BulkStudentRow> rows) async {
_isWorking = true;
_bulkTotal = rows.length;
_bulkDone = 0;
notifyListeners();
int successCount = 0;
final failures = <String>[];
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();
}
}
}