학생 계정 엑셀 일괄 등록 기능 추가 (신입생 등)
- 학생 계정 관리 화면에 드래그앤드롭/클릭 업로드 영역 추가 (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>
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
// 👥 교사용 학생 계정 관리 화면 (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';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -22,6 +25,7 @@ class _TeacherStudentManagementPageState
|
||||
final TextEditingController _addNameController = TextEditingController();
|
||||
final TextEditingController _addPwController = TextEditingController();
|
||||
int? _selectedGrade;
|
||||
bool _isDragging = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -63,6 +67,183 @@ class _TeacherStudentManagementPageState
|
||||
).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();
|
||||
@@ -377,6 +558,90 @@ class _TeacherStudentManagementPageState
|
||||
),
|
||||
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: [
|
||||
|
||||
Reference in New Issue
Block a user