Files
school-attendance/lib/ui/teacher_student_management_page.dart
T
sihooandClaude Sonnet 5 a622249008 학생 계정 엑셀 일괄 등록 기능 추가 (신입생 등)
- 학생 계정 관리 화면에 드래그앤드롭/클릭 업로드 영역 추가
  (excel, desktop_drop, file_picker 패키지 도입)
- .xlsx 파싱 → 미리보기 팝업 → 확인 후 한 명씩 순서대로 계정 생성,
  결과(성공/실패)를 요약해서 보여줌. 초기 비밀번호는 1234 고정
- file_picker의 웹 구현체가 pickFile(s)를 아직 구현하지 않아서
  (UnimplementedError) 웹에서는 package:web으로 직접 <input type=file>을
  다뤄서 우회 (lib/function/excel_file_picker_web.dart), 다른 플랫폼은
  기존 file_picker 사용 (excel_file_picker_io.dart)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 22:44:40 +09:00

799 lines
30 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 👥 교사용 학생 계정 관리 화면 (UI 전용). 신규 학생 계정 추가 폼과 강제 삭제 다이얼로그를 그린다.
// 서버 통신/상태는 lib/function/teacher_student_management_controller.dart가 담당한다.
import 'dart:typed_data';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import '../function/excel_file_picker.dart';
import '../function/teacher_student_management_controller.dart';
// -----------------------------------------------------------------------------
// 👥 [서브 화면 2] 학생 계정 관리 란 (추가 양식 폼 + 강제 삭제 다이얼로그 완전 내장)
// -----------------------------------------------------------------------------
class TeacherStudentManagementPage extends StatefulWidget {
const TeacherStudentManagementPage({super.key});
@override
State<TeacherStudentManagementPage> createState() =>
_TeacherStudentManagementPageState();
}
class _TeacherStudentManagementPageState
extends State<TeacherStudentManagementPage> {
final TeacherStudentManagementController _controller =
TeacherStudentManagementController();
final TextEditingController _addIdController = TextEditingController();
final TextEditingController _addNameController = TextEditingController();
final TextEditingController _addPwController = TextEditingController();
int? _selectedGrade;
bool _isDragging = false;
@override
void dispose() {
_controller.dispose();
_addIdController.dispose();
_addNameController.dispose();
_addPwController.dispose();
super.dispose();
}
// ➕ [학생 추가 버튼 동작]
Future<void> _addStudentAccount() async {
final sId = _addIdController.text.trim();
final sName = _addNameController.text.trim();
final sPw = _addPwController.text.trim();
if (sId.isEmpty || sName.isEmpty || sPw.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('⚠️ 모든 입력란을 채워주세요.')));
return;
}
final (success, message) = await _controller.addStudentAccount(
studentId: sId,
name: sName,
password: sPw,
grade: _selectedGrade,
);
if (!mounted) return;
if (success) {
_addIdController.clear();
_addNameController.clear();
_addPwController.clear();
setState(() => _selectedGrade = null);
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// 📄 [파일 선택 버튼 동작] "파일 선택"으로 엑셀 고르기
Future<void> _pickExcelFile() async {
final bytes = await pickExcelFileBytes();
if (bytes == null) return;
if (!mounted) return;
await _handleExcelBytes(bytes);
}
// 📄 엑셀 바이트를 파싱해서 미리보기 다이얼로그를 띄운다 (드래그/파일선택 공용).
Future<void> _handleExcelBytes(Uint8List bytes) async {
List<BulkStudentRow> rows;
try {
rows = _controller.parseExcelBytes(bytes);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('❌ 엑셀 파일을 읽는 데 실패했습니다: $e')));
return;
}
if (rows.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('⚠️ 유효한 학생 데이터를 찾지 못했습니다. (1행은 머리글로 건너뜁니다)'),
),
);
return;
}
_showBulkPreviewDialog(rows);
}
// 📋 [일괄 등록 확인 팝업] 엑셀에서 뽑아낸 명단을 미리 보여주고 확정받는다.
void _showBulkPreviewDialog(List<BulkStudentRow> rows) {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Text('📄 ${rows.length}명 확인됨'),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'아래 명단으로 계정을 일괄 생성합니다 (초기 비밀번호: 1234).',
style: TextStyle(color: Colors.black54, fontSize: 13),
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: ListView.builder(
shrinkWrap: true,
itemCount: rows.length,
itemBuilder: (context, i) {
final row = rows[i];
return ListTile(
dense: true,
leading: CircleAvatar(
radius: 14,
child: Text(
'${i + 1}',
style: const TextStyle(fontSize: 11),
),
),
title: Text('${row.name} (${row.studentId})'),
trailing: Text(
row.grade != null ? '${row.grade}학년' : '미배정',
style: TextStyle(color: Colors.grey[600]),
),
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
_runBulkImport(rows);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text('${rows.length}명 일괄 등록'),
),
],
),
);
}
// 🚀 실제 일괄 등록 실행 + 진행 상황 표시 + 결과 요약 팝업.
Future<void> _runBulkImport(List<BulkStudentRow> rows) async {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => ListenableBuilder(
listenable: _controller,
builder: (context, _) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
content: Row(
children: [
const CircularProgressIndicator(),
const SizedBox(width: 20),
Text(
'등록 중... (${_controller.bulkDone}/${_controller.bulkTotal})',
),
],
),
),
),
);
final result = await _controller.bulkAddStudents(rows);
if (!mounted) return;
Navigator.pop(context); // 진행 팝업 닫기
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Text(
result.failures.isEmpty ? '✅ 일괄 등록 완료' : '⚠️ 일괄 등록 완료 (일부 실패)',
),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'성공 ${result.successCount}명 / 실패 ${result.failures.length}명',
),
if (result.failures.isNotEmpty) ...[
const SizedBox(height: 12),
const Text(
'실패 목록',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: SingleChildScrollView(
child: Text(
result.failures.join('\n'),
style: const TextStyle(fontSize: 12, color: Colors.red),
),
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('확인'),
),
],
),
);
}
// 🏫 [기존 학생 학년 지정/변경 다이얼로그]
void _showUpdateGradeDialog() {
final TextEditingController idController = TextEditingController();
int? grade;
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<int?>(
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),
),
),
],
);
},
);
},
);
}
// ❌ [학생 삭제 버튼 동작]
Future<void> _deleteStudentAccount(String studentId) async {
final (_, message) = await _controller.deleteStudentAccount(studentId);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// 🚨 계정 삭제 확인 팝업창 모달
void _showDeleteDialog() {
final TextEditingController deleteIdController = TextEditingController();
showDialog(
context: context,
builder: (context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
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(
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),
),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar(
title: const Text(
'⚙️ 학생 통합 관리 센터',
style: TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
elevation: 0,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🏷️ 인디케이터 바 1 (추가 메뉴)
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'신규 학생 계정 추가',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
// 📝 학생 추가 컨테이너 폼
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 16,
),
],
),
child: Column(
children: [
TextField(
controller: _addIdController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '학번',
prefixIcon: Icon(Icons.badge),
),
),
const SizedBox(height: 12),
TextField(
controller: _addNameController,
decoration: const InputDecoration(
labelText: '이름',
prefixIcon: Icon(Icons.person),
),
),
const SizedBox(height: 12),
TextField(
controller: _addPwController,
obscureText: true,
decoration: const InputDecoration(
labelText: '초기 비밀번호',
prefixIcon: Icon(Icons.lock),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
initialValue: _selectedGrade,
decoration: const InputDecoration(
labelText: '학년 (선택)',
prefixIcon: Icon(Icons.class_),
),
items: const [
DropdownMenuItem(value: 1, child: Text('1학년')),
DropdownMenuItem(value: 2, child: Text('2학년')),
DropdownMenuItem(value: 3, child: Text('3학년')),
],
onChanged: (value) =>
setState(() => _selectedGrade = value),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _controller.isWorking
? null
: _addStudentAccount,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _controller.isWorking
? const CircularProgressIndicator(
color: Colors.white,
)
: const Text(
'학생 등록 완료',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
),
],
),
),
const SizedBox(height: 36),
// 🏷️ 인디케이터 바 (엑셀 일괄 등록)
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'엑셀로 한번에 추가 (신입생 등)',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
DropTarget(
onDragEntered: (_) => setState(() => _isDragging = true),
onDragExited: (_) => setState(() => _isDragging = false),
onDragDone: (details) async {
setState(() => _isDragging = false);
if (details.files.isEmpty) return;
final bytes = await details.files.first.readAsBytes();
if (!mounted) return;
await _handleExcelBytes(bytes);
},
child: InkWell(
onTap: _pickExcelFile,
borderRadius: BorderRadius.circular(24),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 28),
decoration: BoxDecoration(
color: _isDragging ? Colors.orange[50] : Colors.white,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: _isDragging
? Colors.orange
: Colors.grey.shade300,
width: _isDragging ? 2 : 1,
),
),
child: Column(
children: [
Icon(
Icons.upload_file_rounded,
size: 40,
color: _isDragging
? Colors.orange
: Colors.grey[400],
),
const SizedBox(height: 12),
Text(
_isDragging ? '여기에 놓으세요' : '엑셀 파일을 드래그하거나 눌러서 선택',
style: TextStyle(
fontWeight: FontWeight.bold,
color: _isDragging
? Colors.orange[800]
: Colors.black87,
),
),
const SizedBox(height: 4),
Text(
'.xlsx · 1행은 머리글, A열=학번 B열=이름 C열=학년(선택)',
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
textAlign: TextAlign.center,
),
],
),
),
),
),
const SizedBox(height: 36),
// 🏷️ 인디케이터 바 (기존 학생 학년 지정/변경)
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(2),
),
),
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 SizedBox(height: 16),
// 🚨 학생 삭제 트리거 카드
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,
),
),
],
),
),
Icon(
Icons.arrow_forward_ios_rounded,
color: Colors.red,
size: 16,
),
],
),
),
),
],
),
),
);
},
);
}
}