From 94ba3efff75183e5108c3dc83a0570026521ae2b Mon Sep 17 00:00:00 2001 From: sihoo Date: Sun, 30 Aug 2026 01:23:46 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=A0=EC=83=9D=EB=8B=98/=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EA=B3=84=EC=A0=95=EC=9D=98=20=ED=95=99=EC=83=9D=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EA=B4=80=EB=A6=AC=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=9D=84=20=ED=95=98=EB=82=98=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 두 화면이 서로 다른 기능만 갖고 있던 문제 해결: 선생님 화면(엑셀 일괄등록, 학년 지정, 커스텀 초기비번)에 마스터 화면에만 있던 실시간 학생 목록 조회 + 기기 리셋(학생이 폰 바꿨을 때 재등록용) 기능을 합침 - 학번을 직접 타이핑하던 블라인드 학년변경/삭제 다이얼로그를 목록의 행별 액션(학년 칩, 기기 리셋 버튼, 삭제 버튼)으로 교체 - 계정 생성/삭제/학년변경 성공 시 목록 자동 새로고침 - 마스터(2061) 계정의 "학생 계정 관리" 카드도 이 통합 화면으로 연결하고, 중복되던 student_management_screen.dart / student_management_controller.dart 삭제 Co-Authored-By: Claude Sonnet 5 --- .../student_management_controller.dart | 102 ---- ...teacher_student_management_controller.dart | 59 +- lib/ui/student_dashboard.dart | 4 +- lib/ui/student_management_screen.dart | 307 ----------- lib/ui/teacher_student_management_page.dart | 515 ++++++++---------- 5 files changed, 288 insertions(+), 699 deletions(-) delete mode 100644 lib/function/student_management_controller.dart delete mode 100644 lib/ui/student_management_screen.dart diff --git a/lib/function/student_management_controller.dart b/lib/function/student_management_controller.dart deleted file mode 100644 index 7e71c20..0000000 --- a/lib/function/student_management_controller.dart +++ /dev/null @@ -1,102 +0,0 @@ -// 🔐 (마스터 계정용) 학생 계정 및 기기 관리 화면의 기능(서버 통신/상태) 담당 컨트롤러. -import 'dart:convert'; -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; -import '../config.dart'; - -class StudentManagementController extends ChangeNotifier { - List _students = []; - bool _isLoading = true; - - List get students => _students; - bool get isLoading => _isLoading; - - Future init() => fetchStudents(); - - /// 🌐 서버에서 전체 학생 목록 불러오기. 실패 시 에러 메시지를 반환한다(성공 시 null). - Future fetchStudents() async { - _isLoading = 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'] ?? []; - _isLoading = false; - notifyListeners(); - return null; - } - _isLoading = false; - notifyListeners(); - return '데이터 불러오기 실패 (${response.statusCode})'; - } catch (e) { - _isLoading = false; - notifyListeners(); - return '데이터 불러오기 실패: $e'; - } - } - - /// 🌐 서버로 기기 초기화(리셋) 명령 보내기 - Future<(bool success, String message)> resetDevice( - String studentId, - String studentName, - ) async { - 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 학생 기기 초기화 완료!'); - } - return (false, '❌ 초기화 통신 실패'); - } catch (e) { - return (false, '❌ 초기화 통신 실패'); - } - } - - /// 🌐 서버로 계정 완전 삭제 명령 보내기 - Future<(bool success, String message)> deleteUser( - String studentId, - String studentName, - ) async { - try { - final response = await http.delete( - Uri.parse('$baseUrl/api/users/delete'), - headers: {"Content-Type": "application/json"}, - body: jsonEncode({"studentId": studentId}), - ); - if (response.statusCode == 200) { - await fetchStudents(); - return (true, '💥 $studentName 학생의 계정이 영구 삭제되었습니다.'); - } - return (false, '❌ 계정 삭제 통신 실패'); - } catch (e) { - return (false, '❌ 계정 삭제 통신 실패'); - } - } - - /// 🌐 신규 학생 계정 생성 - Future<(bool success, String message)> createUser( - String studentId, - String name, - ) async { - try { - final response = await http.post( - Uri.parse('$baseUrl/api/users/create'), - headers: {"Content-Type": "application/json"}, - body: jsonEncode({"studentId": studentId, "name": name}), - ); - final res = jsonDecode(response.body); - if (res['status'] == 'success') { - await fetchStudents(); - return (true, '✅ ${res['message']}'); - } - return (false, '❌ ${res['message']}'); - } catch (e) { - return (false, '❌ 학생 등록 실패 (통신 에러)'); - } - } -} diff --git a/lib/function/teacher_student_management_controller.dart b/lib/function/teacher_student_management_controller.dart index 293d500..988af49 100644 --- a/lib/function/teacher_student_management_controller.dart +++ b/lib/function/teacher_student_management_controller.dart @@ -35,6 +35,58 @@ class TeacherStudentManagementController extends ChangeNotifier { int get bulkTotal => _bulkTotal; int get bulkDone => _bulkDone; + List _students = []; + bool _isLoadingStudents = true; + List get students => _students; + bool get isLoadingStudents => _isLoadingStudents; + + 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열=학년(선택, 숫자) 순서를 기대한다. /// 학번이나 이름이 비어있는 줄은 조용히 무시한다. @@ -105,6 +157,7 @@ class TeacherStudentManagementController extends ChangeNotifier { notifyListeners(); } + await fetchStudents(); _isWorking = false; notifyListeners(); return BulkImportResult(successCount: successCount, failures: failures); @@ -120,12 +173,14 @@ class TeacherStudentManagementController extends ChangeNotifier { _isWorking = true; notifyListeners(); try { - return await _createStudentRequest( + final result = await _createStudentRequest( studentId: studentId, name: name, password: password, grade: grade, ); + if (result.$1) await fetchStudents(); + return result; } finally { _isWorking = false; notifyListeners(); @@ -182,6 +237,7 @@ class TeacherStudentManagementController extends ChangeNotifier { 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'] ?? '오류 발생'}'); @@ -209,6 +265,7 @@ class TeacherStudentManagementController extends ChangeNotifier { ); if (response.statusCode == 200) { final resData = jsonDecode(utf8.decode(response.bodyBytes)); + await fetchStudents(); return (true, '✅ ${resData['message']}'); } else { return (false, '❌ 삭제 실패하였습니다.'); diff --git a/lib/ui/student_dashboard.dart b/lib/ui/student_dashboard.dart index fac5f0f..fa0a9e0 100644 --- a/lib/ui/student_dashboard.dart +++ b/lib/ui/student_dashboard.dart @@ -7,7 +7,7 @@ import 'nfc_poccket_checkin_screen.dart'; import 'login_screen.dart'; import 'teacher_attendance_page.dart'; import 'admin_dashboard.dart'; -import 'student_management_screen.dart'; +import 'teacher_student_management_page.dart'; import 'nfc_tag_writer_screen.dart'; // ------------------------------------------------------------- @@ -384,7 +384,7 @@ class _StudentDashboardState extends State { context, MaterialPageRoute( builder: (context) => - const StudentManagementScreen(), + const TeacherStudentManagementPage(), ), ), ), diff --git a/lib/ui/student_management_screen.dart b/lib/ui/student_management_screen.dart deleted file mode 100644 index 77232d2..0000000 --- a/lib/ui/student_management_screen.dart +++ /dev/null @@ -1,307 +0,0 @@ -// 🔐 (마스터 계정용) 학생 계정 및 기기 관리 화면 (UI 전용). 기기 UUID 초기화/계정 삭제/생성 다이얼로그를 그린다. -// 서버 통신/상태는 lib/function/student_management_controller.dart가 담당한다. -import 'package:flutter/material.dart'; -import '../function/student_management_controller.dart'; - -// ========================================== -// 🔐 6. 학생 계정 및 기기 관리 화면 (기기 리셋 + 계정 삭제 완본) -// ========================================== -class StudentManagementScreen extends StatefulWidget { - const StudentManagementScreen({super.key}); - - @override - State createState() => - _StudentManagementScreenState(); -} - -class _StudentManagementScreenState extends State { - final StudentManagementController _controller = - StudentManagementController(); - - @override - void initState() { - super.initState(); - _controller.init(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - Future _fetchStudents() async { - final error = await _controller.fetchStudents(); - if (error != null && mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error))); - } - } - - // 🌐 서버로 기기 초기화(리셋) 명령 보내기 - void _resetDevice(String studentId, String studentName) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text( - '⚠️ 기기 잠금 해제', - style: TextStyle(fontWeight: FontWeight.bold, color: Colors.purple), - ), - content: Text('$studentName 학생의 스마트폰 기기 등록을 초기화하시겠습니까?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('취소', style: TextStyle(color: Colors.grey)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.purple, - foregroundColor: Colors.white, - ), - onPressed: () async { - Navigator.pop(ctx); - final (_, message) = await _controller.resetDevice( - studentId, - studentName, - ); - if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); - }, - child: const Text('초기화 승인'), - ), - ], - ), - ); - } - - // 🌐 [새 기능] 서버로 계정 완전 삭제 명령 보내기 - void _deleteUser(String studentId, String studentName) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text( - '🚨 계정 완전 삭제 경고', - style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red), - ), - content: Text( - '정말로 $studentName ($studentId) 학생의 계정을 시스템에서 탈퇴(삭제)시키겠습니까?\n\n이 작업은 되돌릴 수 없으며, 해당 학생은 다시 회원가입을 진행해야 합니다.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('취소', style: TextStyle(color: Colors.grey)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - ), - onPressed: () async { - Navigator.pop(ctx); - final (_, message) = await _controller.deleteUser( - studentId, - studentName, - ); - if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); - }, - child: const Text('영구 삭제'), - ), - ], - ), - ); - } - - void _showCreateUserDialog() { - final idController = TextEditingController(); - final nameController = TextEditingController(); - - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('👤 신규 학생 계정 추가'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: idController, - decoration: const InputDecoration(labelText: '학번 입력 (예: 20101)'), - keyboardType: TextInputType.number, - ), - const SizedBox(height: 8), - TextField( - controller: nameController, - decoration: const InputDecoration(labelText: '학생 이름 입력'), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('취소'), - ), - ElevatedButton( - onPressed: () async { - String studentId = idController.text.trim(); - String name = nameController.text.trim(); - - if (studentId.isEmpty || name.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('⚠️ 학번과 이름을 모두 입력하세요.')), - ); - return; - } - - final (success, message) = await _controller.createUser( - studentId, - name, - ); - if (!mounted) return; - if (success) { - Navigator.pop(context); // 팝업 닫기 - } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); - }, - child: const Text('생성'), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return ListenableBuilder( - listenable: _controller, - builder: (context, _) { - final realStudents = _controller.students; - return Scaffold( - backgroundColor: Colors.grey[50], - appBar: AppBar( - title: const Text( - '학생 계정 및 기기 관리', - style: TextStyle(fontWeight: FontWeight.bold), - ), - backgroundColor: Colors.purple, - foregroundColor: Colors.white, - actions: [ - IconButton( - icon: const Icon(Icons.person_add_alt_1_rounded), - onPressed: () => _showCreateUserDialog(), - ), - IconButton(icon: const Icon(Icons.refresh), onPressed: _fetchStudents), - ], - ), - body: _controller.isLoading - ? const Center( - child: CircularProgressIndicator(color: Colors.purple), - ) - : realStudents.isEmpty - ? const Center( - child: Text( - '가입된 학생 계정이 없습니다.', - style: TextStyle(color: Colors.grey, fontSize: 16), - ), - ) - : ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: realStudents.length, - itemBuilder: (context, index) { - final student = realStudents[index]; - final bool needsReset = student['device'] == "초기화 필요"; - - return Card( - elevation: 2, - margin: const EdgeInsets.only(bottom: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - leading: CircleAvatar( - backgroundColor: needsReset - ? Colors.red[50] - : Colors.purple[50], - child: Icon( - needsReset ? Icons.lock_reset : Icons.person, - color: needsReset ? Colors.red : Colors.purple, - ), - ), - title: Text( - '${student['name']} (${student['id']})', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - subtitle: Text( - needsReset ? '기기 초기화 승인 대기중' : '정상 등록 상태', - style: TextStyle( - color: needsReset ? Colors.red : Colors.grey, - fontWeight: needsReset - ? FontWeight.bold - : FontWeight.normal, - ), - ), - // 🕹️ 오른쪽 끝에 [기기 리셋] 버튼과 [쓰레기통(삭제)] 버튼을 나란히 배치하는 Row 레이아웃 기획 - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (!needsReset) - OutlinedButton( - style: OutlinedButton.styleFrom( - foregroundColor: Colors.purple, - side: BorderSide( - color: Colors.purple.withValues( - alpha: 0.5, - ), - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onPressed: () => _resetDevice( - student['id'], - student['name'], - ), - child: const Text('기기 리셋'), - ) - else - const Icon( - Icons.check_circle, - color: Colors.green, - ), - - const SizedBox(width: 8), - - // 🔴 [새 버튼] 최고권한 마스터 전용 계정 영구 삭제 쓰레기통 버튼! - IconButton( - icon: const Icon( - Icons.delete_forever_rounded, - color: Colors.redAccent, - ), - tooltip: '계정 영구 삭제', - onPressed: () => - _deleteUser(student['id'], student['name']), - ), - ], - ), - ), - ); - }, - ), - ); - }, - ); - } -} diff --git a/lib/ui/teacher_student_management_page.dart b/lib/ui/teacher_student_management_page.dart index 0d5a403..bccdd5f 100644 --- a/lib/ui/teacher_student_management_page.dart +++ b/lib/ui/teacher_student_management_page.dart @@ -27,6 +27,12 @@ class _TeacherStudentManagementPageState int? _selectedGrade; bool _isDragging = false; + @override + void initState() { + super.initState(); + _controller.init(); + } + @override void dispose() { _controller.dispose(); @@ -244,185 +250,125 @@ class _TeacherStudentManagementPageState ); } - // 🏫 [기존 학생 학년 지정/변경 다이얼로그] - void _showUpdateGradeDialog() { - final TextEditingController idController = TextEditingController(); - int? grade; + // 🏫 [목록 행의 "학년" 칩] 눌러서 바로 학년 변경 (미배정/1/2/3학년 중 선택). + Future _showGradeMenu( + BuildContext tileContext, + String studentId, + String studentName, + int? currentGrade, + ) async { + final RenderBox box = tileContext.findRenderObject() as RenderBox; + final Offset position = box.localToGlobal(Offset.zero); - showDialog( - context: context, - builder: (context) { - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - title: const Text( - '🏫 학년 지정/변경', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '이미 만들어진 학생 계정의 학년을 지정하거나 바꿉니다.', - style: TextStyle(color: Colors.black54, fontSize: 13), - ), - const SizedBox(height: 16), - TextField( - controller: idController, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: '학번', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.badge), - ), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: grade, - decoration: const InputDecoration( - labelText: '학년', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.class_), - ), - items: const [ - DropdownMenuItem(value: null, child: Text('미배정')), - DropdownMenuItem(value: 1, child: Text('1학년')), - DropdownMenuItem(value: 2, child: Text('2학년')), - DropdownMenuItem(value: 3, child: Text('3학년')), - ], - onChanged: (value) => setDialogState(() => grade = value), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('취소', style: TextStyle(color: Colors.grey)), - ), - ElevatedButton( - onPressed: () async { - final inputId = idController.text.trim(); - if (inputId.isEmpty) return; - Navigator.pop(context); - final (_, message) = await _controller.updateStudentGrade( - studentId: inputId, - grade: grade, - ); - if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: const Text( - '적용', - style: TextStyle(fontWeight: FontWeight.bold), - ), - ), - ], - ); - }, - ); - }, + final selected = await showMenu( + context: tileContext, + position: RelativeRect.fromLTRB( + position.dx, + position.dy + box.size.height, + position.dx, + 0, + ), + items: const [ + PopupMenuItem(value: null, child: Text('미배정')), + PopupMenuItem(value: 1, child: Text('1학년')), + PopupMenuItem(value: 2, child: Text('2학년')), + PopupMenuItem(value: 3, child: Text('3학년')), + ], ); - } + if (selected == currentGrade) return; // 취소했거나 같은 값 선택 + if (!mounted) return; - // ❌ [학생 삭제 버튼 동작] - Future _deleteStudentAccount(String studentId) async { - final (_, message) = await _controller.deleteStudentAccount(studentId); + final (_, message) = await _controller.updateStudentGrade( + studentId: studentId, + grade: selected, + ); if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } - // 🚨 계정 삭제 확인 팝업창 모달 - void _showDeleteDialog() { - final TextEditingController deleteIdController = TextEditingController(); + // 🔓 [목록 행의 "기기 리셋" 버튼] 학생이 폰을 바꿨을 때 등록 초기화. + void _confirmResetDevice(String studentId, String studentName) { showDialog( context: context, - builder: (context) { - return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), + builder: (ctx) => AlertDialog( + title: const Text( + '⚠️ 기기 등록 초기화', + style: TextStyle(fontWeight: FontWeight.bold, color: Colors.purple), + ), + content: Text('$studentName 학생의 스마트폰 기기 등록과 비밀번호(1234)를 초기화하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('취소', style: TextStyle(color: Colors.grey)), ), - title: Row( - children: [ - Icon(Icons.warning_amber_rounded, color: Colors.orange[700]), - const SizedBox(width: 10), - const Text( - '학생 계정 강제 삭제', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), - ), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - '영구 삭제할 학생의 학번을 정확하게 입력하세요.', - style: TextStyle(color: Colors.black54, fontSize: 13), - ), - const SizedBox(height: 16), - TextField( - controller: deleteIdController, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: '학번 입력', - labelStyle: TextStyle(color: Colors.orange[700]), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: Colors.orange[700]!, - width: 2, - ), - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - ), - prefixIcon: const Icon(Icons.no_accounts_rounded), - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('취소', style: TextStyle(color: Colors.grey)), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.purple, + foregroundColor: Colors.white, ), - ElevatedButton( - onPressed: () { - final inputId = deleteIdController.text.trim(); - if (inputId.isNotEmpty) { - Navigator.pop(context); - _deleteStudentAccount(inputId); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.orange[700], - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: const Text( - '삭제 확정', - style: TextStyle(fontWeight: FontWeight.bold), - ), + onPressed: () async { + Navigator.pop(ctx); + final (_, message) = await _controller.resetDevice( + studentId, + studentName, + ); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + }, + child: const Text('초기화 승인'), + ), + ], + ), + ); + } + + // 🚨 [목록 행의 삭제 버튼] 계정 완전 삭제 확인 팝업. + void _confirmDeleteStudent(String studentId, String studentName) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + title: Row( + children: [ + Icon(Icons.warning_amber_rounded, color: Colors.red[700]), + const SizedBox(width: 10), + const Text( + '계정 완전 삭제', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), ), ], - ); - }, + ), + content: Text( + '정말로 $studentName ($studentId) 학생의 계정을 영구 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('취소', style: TextStyle(color: Colors.grey)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red[600], + foregroundColor: Colors.white, + ), + onPressed: () async { + Navigator.pop(ctx); + final (_, message) = await _controller.deleteStudentAccount( + studentId, + ); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + }, + child: const Text('영구 삭제'), + ), + ], + ), ); } @@ -642,7 +588,7 @@ class _TeacherStudentManagementPageState ), const SizedBox(height: 36), - // 🏷️ 인디케이터 바 (기존 학생 학년 지정/변경) + // 🏷️ 인디케이터 바 (전체 학생 목록) Row( children: [ Container( @@ -655,139 +601,134 @@ class _TeacherStudentManagementPageState ), const SizedBox(width: 8), const Text( - '기존 학생 학년 지정/변경', + '전체 학생 목록', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), - ], - ), - const SizedBox(height: 16), - - InkWell( - onTap: _showUpdateGradeDialog, - borderRadius: BorderRadius.circular(24), - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.blue[50], - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Colors.blue.shade200), - ), - child: const Row( - children: [ - Icon(Icons.class_rounded, color: Colors.blue, size: 32), - SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '학번으로 학년 지정', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.blue, - ), - ), - SizedBox(height: 2), - Text( - '이미 만든 계정의 학년을 나중에 지정하거나 바꿀 때 사용', - style: TextStyle( - fontSize: 12, - color: Colors.blueAccent, - ), - ), - ], - ), - ), - Icon( - Icons.arrow_forward_ios_rounded, - color: Colors.blue, - size: 16, - ), - ], - ), - ), - ), - const SizedBox(height: 36), - - // 🏷️ 인디케이터 바 2 (삭제 권한 제어메뉴) - Row( - children: [ - Container( - width: 4, - height: 16, - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 8), - const Text( - '위험 구역 (Account Reset)', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.redAccent, - ), + const Spacer(), + IconButton( + icon: const Icon(Icons.refresh_rounded), + onPressed: _controller.isLoadingStudents + ? null + : _controller.fetchStudents, + tooltip: '새로고침', ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 8), + const Text( + '학년 칩을 눌러 학년을 바꾸고, 기기 리셋은 학생이 폰을 바꿨을 때 사용하세요.', + style: TextStyle(color: Colors.black54, fontSize: 12), + ), + const SizedBox(height: 12), - // 🚨 학생 삭제 트리거 카드 - InkWell( - onTap: _showDeleteDialog, - borderRadius: BorderRadius.circular(24), - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.red[50], - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Colors.red.shade200), - ), - child: const Row( - children: [ - Icon( - Icons.delete_sweep_rounded, - color: Colors.red, - size: 32, - ), - SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '학생 계정 강제 원격 삭제', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.red, - ), - ), - SizedBox(height: 2), - Text( - '인증 초기화 및 DB 제거용', - style: TextStyle( - fontSize: 12, - color: Colors.redAccent, - ), - ), - ], + _controller.isLoadingStudents + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center(child: CircularProgressIndicator()), + ) + : _controller.students.isEmpty + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Text( + '가입된 학생 계정이 없습니다.', + style: TextStyle(color: Colors.grey), ), ), - Icon( - Icons.arrow_forward_ios_rounded, - color: Colors.red, - size: 16, - ), - ], - ), - ), - ), + ) + : ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _controller.students.length, + itemBuilder: (context, index) { + final student = _controller.students[index]; + final String studentId = student['id'].toString(); + final String studentName = student['name'].toString(); + final int? grade = student['grade'] as int?; + final bool needsReset = student['device'] == '초기화 필요'; + + return Card( + elevation: 1, + margin: const EdgeInsets.only(bottom: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + leading: CircleAvatar( + backgroundColor: needsReset + ? Colors.red[50] + : Colors.blue[50], + child: Icon( + needsReset ? Icons.lock_reset : Icons.person, + color: needsReset ? Colors.red : Colors.blue, + ), + ), + title: Text( + '$studentName ($studentId)', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + subtitle: Text( + needsReset ? '기기 초기화 승인 대기중' : '정상 등록 상태', + style: TextStyle( + color: needsReset ? Colors.red : Colors.grey, + fontWeight: needsReset + ? FontWeight.bold + : FontWeight.normal, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Builder( + builder: (chipContext) => ActionChip( + label: Text( + grade != null ? '$grade학년' : '미배정', + ), + onPressed: () => _showGradeMenu( + chipContext, + studentId, + studentName, + grade, + ), + ), + ), + IconButton( + icon: const Icon( + Icons.lock_reset, + color: Colors.purple, + ), + tooltip: '기기 리셋', + onPressed: () => _confirmResetDevice( + studentId, + studentName, + ), + ), + IconButton( + icon: const Icon( + Icons.delete_forever_rounded, + color: Colors.redAccent, + ), + tooltip: '계정 영구 삭제', + onPressed: () => _confirmDeleteStudent( + studentId, + studentName, + ), + ), + ], + ), + ), + ); + }, + ), ], ), ),