선생님/마스터 계정의 학생 계정 관리 화면을 하나로 통합

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 01:23:46 +09:00
co-authored by Claude Sonnet 5
parent a622249008
commit 94ba3efff7
5 changed files with 288 additions and 699 deletions
@@ -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<dynamic> _students = [];
bool _isLoading = true;
List<dynamic> get students => _students;
bool get isLoading => _isLoading;
Future<void> init() => fetchStudents();
/// 🌐 서버에서 전체 학생 목록 불러오기. 실패 시 에러 메시지를 반환한다(성공 시 null).
Future<String?> 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, '❌ 학생 등록 실패 (통신 에러)');
}
}
}
@@ -35,6 +35,58 @@ class TeacherStudentManagementController extends ChangeNotifier {
int get bulkTotal => _bulkTotal; int get bulkTotal => _bulkTotal;
int get bulkDone => _bulkDone; 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행은 머리글로 보고 건너뛰며, /// 📄 엑셀 파일 바이트를 파싱한다. 1행은 머리글로 보고 건너뛰며,
/// A열=학번, B열=이름, C열=학년(선택, 숫자) 순서를 기대한다. /// A열=학번, B열=이름, C열=학년(선택, 숫자) 순서를 기대한다.
/// 학번이나 이름이 비어있는 줄은 조용히 무시한다. /// 학번이나 이름이 비어있는 줄은 조용히 무시한다.
@@ -105,6 +157,7 @@ class TeacherStudentManagementController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
await fetchStudents();
_isWorking = false; _isWorking = false;
notifyListeners(); notifyListeners();
return BulkImportResult(successCount: successCount, failures: failures); return BulkImportResult(successCount: successCount, failures: failures);
@@ -120,12 +173,14 @@ class TeacherStudentManagementController extends ChangeNotifier {
_isWorking = true; _isWorking = true;
notifyListeners(); notifyListeners();
try { try {
return await _createStudentRequest( final result = await _createStudentRequest(
studentId: studentId, studentId: studentId,
name: name, name: name,
password: password, password: password,
grade: grade, grade: grade,
); );
if (result.$1) await fetchStudents();
return result;
} finally { } finally {
_isWorking = false; _isWorking = false;
notifyListeners(); notifyListeners();
@@ -182,6 +237,7 @@ class TeacherStudentManagementController extends ChangeNotifier {
final result = jsonDecode(utf8.decode(response.bodyBytes)); final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') { if (response.statusCode == 200 && result['status'] == 'success') {
await fetchStudents();
return (true, '✅ ${result['message'] ?? '학년이 지정되었습니다.'}'); return (true, '✅ ${result['message'] ?? '학년이 지정되었습니다.'}');
} else { } else {
return (false, '❌ 지정 실패: ${result['message'] ?? '오류 발생'}'); return (false, '❌ 지정 실패: ${result['message'] ?? '오류 발생'}');
@@ -209,6 +265,7 @@ class TeacherStudentManagementController extends ChangeNotifier {
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
final resData = jsonDecode(utf8.decode(response.bodyBytes)); final resData = jsonDecode(utf8.decode(response.bodyBytes));
await fetchStudents();
return (true, '✅ ${resData['message']}'); return (true, '✅ ${resData['message']}');
} else { } else {
return (false, '❌ 삭제 실패하였습니다.'); return (false, '❌ 삭제 실패하였습니다.');
+2 -2
View File
@@ -7,7 +7,7 @@ import 'nfc_poccket_checkin_screen.dart';
import 'login_screen.dart'; import 'login_screen.dart';
import 'teacher_attendance_page.dart'; import 'teacher_attendance_page.dart';
import 'admin_dashboard.dart'; import 'admin_dashboard.dart';
import 'student_management_screen.dart'; import 'teacher_student_management_page.dart';
import 'nfc_tag_writer_screen.dart'; import 'nfc_tag_writer_screen.dart';
// ------------------------------------------------------------- // -------------------------------------------------------------
@@ -384,7 +384,7 @@ class _StudentDashboardState extends State<StudentDashboard> {
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => builder: (context) =>
const StudentManagementScreen(), const TeacherStudentManagementPage(),
), ),
), ),
), ),
-307
View File
@@ -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<StudentManagementScreen> createState() =>
_StudentManagementScreenState();
}
class _StudentManagementScreenState extends State<StudentManagementScreen> {
final StudentManagementController _controller =
StudentManagementController();
@override
void initState() {
super.initState();
_controller.init();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _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']),
),
],
),
),
);
},
),
);
},
);
}
}
+187 -246
View File
@@ -27,6 +27,12 @@ class _TeacherStudentManagementPageState
int? _selectedGrade; int? _selectedGrade;
bool _isDragging = false; bool _isDragging = false;
@override
void initState() {
super.initState();
_controller.init();
}
@override @override
void dispose() { void dispose() {
_controller.dispose(); _controller.dispose();
@@ -244,185 +250,125 @@ class _TeacherStudentManagementPageState
); );
} }
// 🏫 [기존 학생 학년 지정/변경 다이얼로그] // 🏫 [목록 행의 "학년" 칩] 눌러서 바로 학년 변경 (미배정/1/2/3학년 중 선택).
void _showUpdateGradeDialog() { Future<void> _showGradeMenu(
final TextEditingController idController = TextEditingController(); BuildContext tileContext,
int? grade; String studentId,
String studentName,
int? currentGrade,
) async {
final RenderBox box = tileContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset.zero);
showDialog( final selected = await showMenu<int?>(
context: context, context: tileContext,
builder: (context) { position: RelativeRect.fromLTRB(
return StatefulBuilder( position.dx,
builder: (context, setDialogState) { position.dy + box.size.height,
return AlertDialog( position.dx,
shape: RoundedRectangleBorder( 0,
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<int?>(
initialValue: grade,
decoration: const InputDecoration(
labelText: '학년',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.class_),
), ),
items: const [ items: const [
DropdownMenuItem(value: null, child: Text('미배정')), PopupMenuItem(value: null, child: Text('미배정')),
DropdownMenuItem(value: 1, child: Text('1학년')), PopupMenuItem(value: 1, child: Text('1학년')),
DropdownMenuItem(value: 2, child: Text('2학년')), PopupMenuItem(value: 2, child: Text('2학년')),
DropdownMenuItem(value: 3, child: Text('3학년')), PopupMenuItem(value: 3, child: Text('3학년')),
],
onChanged: (value) => setDialogState(() => grade = value),
),
], ],
);
if (selected == currentGrade) return; // 취소했거나 같은 값 선택
if (!mounted) return;
final (_, message) = await _controller.updateStudentGrade(
studentId: studentId,
grade: selected,
);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// 🔓 [목록 행의 "기기 리셋" 버튼] 학생이 폰을 바꿨을 때 등록 초기화.
void _confirmResetDevice(String studentId, String studentName) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text(
'⚠️ 기기 등록 초기화',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.purple),
), ),
content: Text('$studentName 학생의 스마트폰 기기 등록과 비밀번호(1234)를 초기화하시겠습니까?'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(ctx),
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple,
foregroundColor: Colors.white,
),
onPressed: () async { onPressed: () async {
final inputId = idController.text.trim(); Navigator.pop(ctx);
if (inputId.isEmpty) return; final (_, message) = await _controller.resetDevice(
Navigator.pop(context); studentId,
final (_, message) = await _controller.updateStudentGrade( studentName,
studentId: inputId,
grade: grade,
); );
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text(message))); ).showSnackBar(SnackBar(content: Text(message)));
}, },
style: ElevatedButton.styleFrom( child: const Text('초기화 승인'),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'적용',
style: TextStyle(fontWeight: FontWeight.bold),
),
), ),
], ],
); ),
},
);
},
); );
} }
// ❌ [학생 삭제 버튼 동작] // 🚨 [목록 행의 삭제 버튼] 계정 완전 삭제 확인 팝업.
Future<void> _deleteStudentAccount(String studentId) async { void _confirmDeleteStudent(String studentId, String studentName) {
final (_, message) = await _controller.deleteStudentAccount(studentId);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// 🚨 계정 삭제 확인 팝업창 모달
void _showDeleteDialog() {
final TextEditingController deleteIdController = TextEditingController();
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (ctx) => AlertDialog(
return AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
title: Row( title: Row(
children: [ children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange[700]), Icon(Icons.warning_amber_rounded, color: Colors.red[700]),
const SizedBox(width: 10), const SizedBox(width: 10),
const Text( const Text(
'학생 계정 강제 삭제', '계정 완전 삭제',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
), ),
], ],
), ),
content: Column( content: Text(
mainAxisSize: MainAxisSize.min, '정말로 $studentName ($studentId) 학생의 계정을 영구 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.',
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: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(ctx),
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
ElevatedButton( ElevatedButton(
onPressed: () {
final inputId = deleteIdController.text.trim();
if (inputId.isNotEmpty) {
Navigator.pop(context);
_deleteStudentAccount(inputId);
}
},
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange[700], backgroundColor: Colors.red[600],
foregroundColor: Colors.white, 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.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), const SizedBox(height: 36),
// 🏷️ 인디케이터 바 (기존 학생 학년 지정/변경) // 🏷️ 인디케이터 바 (전체 학생 목록)
Row( Row(
children: [ children: [
Container( Container(
@@ -655,138 +601,133 @@ class _TeacherStudentManagementPageState
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text( const Text(
'기존 학생 학년 지정/변경', '전체 학생 목록',
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
], const Spacer(),
), IconButton(
const SizedBox(height: 16), icon: const Icon(Icons.refresh_rounded),
onPressed: _controller.isLoadingStudents
InkWell( ? null
onTap: _showUpdateGradeDialog, : _controller.fetchStudents,
borderRadius: BorderRadius.circular(24), tooltip: '새로고침',
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,
),
), ),
], ],
), ),
), const SizedBox(height: 8),
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( const Text(
'위험 구역 (Account Reset)', '학년 칩을 눌러 학년을 바꾸고, 기기 리셋은 학생이 폰을 바꿨을 때 사용하세요.',
style: TextStyle( style: TextStyle(color: Colors.black54, fontSize: 12),
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.redAccent,
), ),
), const SizedBox(height: 12),
],
),
const SizedBox(height: 16),
// 🚨 학생 삭제 트리거 카드 _controller.isLoadingStudents
InkWell( ? const Padding(
onTap: _showDeleteDialog, padding: EdgeInsets.symmetric(vertical: 40),
borderRadius: BorderRadius.circular(24), child: Center(child: CircularProgressIndicator()),
child: Container( )
padding: const EdgeInsets.all(20), : _controller.students.isEmpty
decoration: BoxDecoration( ? const Padding(
color: Colors.red[50], padding: EdgeInsets.symmetric(vertical: 24),
borderRadius: BorderRadius.circular(24), child: Center(
border: Border.all(color: Colors.red.shade200), child: Text(
'가입된 학생 계정이 없습니다.',
style: TextStyle(color: Colors.grey),
), ),
child: const Row(
children: [
Icon(
Icons.delete_sweep_rounded,
color: Colors.red,
size: 32,
), ),
SizedBox(width: 16), )
Expanded( : ListView.builder(
child: Column( shrinkWrap: true,
crossAxisAlignment: CrossAxisAlignment.start, physics: const NeverScrollableScrollPhysics(),
children: [ itemCount: _controller.students.length,
Text( itemBuilder: (context, index) {
'학생 계정 강제 원격 삭제', final student = _controller.students[index];
style: TextStyle( final String studentId = student['id'].toString();
fontSize: 16, 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, fontWeight: FontWeight.bold,
color: Colors.red,
), ),
), ),
SizedBox(height: 2), subtitle: Text(
Text( needsReset ? '기기 초기화 승인 대기중' : '정상 등록 상태',
'인증 초기화 및 DB 제거용',
style: TextStyle( style: TextStyle(
fontSize: 12, 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, color: Colors.redAccent,
), ),
tooltip: '계정 영구 삭제',
onPressed: () => _confirmDeleteStudent(
studentId,
studentName,
),
), ),
], ],
), ),
), ),
Icon( );
Icons.arrow_forward_ios_rounded, },
color: Colors.red,
size: 16,
),
],
),
),
), ),
], ],
), ),