학생 계정 엑셀 일괄 등록 기능 추가 (신입생 등)

- 학생 계정 관리 화면에 드래그앤드롭/클릭 업로드 영역 추가
  (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:
2026-08-29 22:44:40 +09:00
co-authored by Claude Sonnet 5
parent db903fcdef
commit a622249008
12 changed files with 568 additions and 10 deletions
+4
View File
@@ -0,0 +1,4 @@
// 📄 엑셀 파일 하나를 골라 바이트로 돌려주는 진입점. 실제 구현은 플랫폼별로 갈라진다
// (web은 file_picker의 웹 구현체에 pickFile(s)가 아직 없어서 dart:html로 직접 처리).
export 'excel_file_picker_io.dart'
if (dart.library.html) 'excel_file_picker_web.dart';
+13
View File
@@ -0,0 +1,13 @@
// 📄 [비웹 플랫폼] file_picker로 엑셀 파일 하나를 고른다.
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
/// 사용자가 엑셀(.xlsx) 파일을 고르면 바이트를 돌려준다. 취소하면 null.
Future<Uint8List?> pickExcelFileBytes() async {
final files = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ['xlsx'],
);
if (files.isEmpty) return null;
return files.first.readAsBytes();
}
+31
View File
@@ -0,0 +1,31 @@
// 📄 [웹 전용] file_picker의 웹 구현체에 pickFile(s)가 아직 없어서(UnimplementedError),
// 숨겨진 <input type="file">를 직접 만들어 처리한다.
import 'dart:async';
import 'dart:js_interop';
import 'dart:typed_data';
import 'package:web/web.dart' as web;
/// 사용자가 엑셀(.xlsx) 파일을 고르면 바이트를 돌려준다. 취소하면 null.
Future<Uint8List?> pickExcelFileBytes() async {
final completer = Completer<web.File?>();
final input = web.HTMLInputElement()
..type = 'file'
..accept = '.xlsx'
..style.display = 'none';
// 문서에 실제로 붙어있지 않으면 일부 브라우저/자동화 환경에서 change 이벤트가
// 안정적으로 안 잡힌다 — 그래서 body에 잠깐 붙였다가 끝나면 반드시 제거한다.
web.document.body!.append(input);
input.onchange = (web.Event event) {
final file = input.files?.item(0);
input.remove();
completer.complete(file);
}.toJS;
input.click();
final file = await completer.future;
if (file == null) return null;
final buffer = await file.arrayBuffer().toDart;
return buffer.toDart.asUint8List();
}
@@ -1,13 +1,115 @@
// 👥 교사용 학생 계정 관리 화면의 기능(서버 통신/상태) 담당 컨트롤러.
// 👥 교사용 학생 계정 관리 화면의 기능(서버 통신/상태/엑셀 일괄등록) 담당 컨트롤러.
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;
/// 📄 엑셀 파일 바이트를 파싱한다. 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();
}
_isWorking = false;
notifyListeners();
return BulkImportResult(successCount: successCount, failures: failures);
}
/// ➕ 학생 계정 추가. (성공여부, 메시지)를 반환한다 — UI가 성공했을 때만 입력칸을 비운다.
Future<(bool success, String message)> addStudentAccount({
required String studentId,
@@ -17,6 +119,28 @@ class TeacherStudentManagementController extends ChangeNotifier {
}) async {
_isWorking = true;
notifyListeners();
try {
return await _createStudentRequest(
studentId: studentId,
name: name,
password: password,
grade: grade,
);
} 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(
@@ -38,9 +162,6 @@ class TeacherStudentManagementController extends ChangeNotifier {
}
} catch (e) {
return (false, '🚨 네트워크 에러: $e');
} finally {
_isWorking = false;
notifyListeners();
}
}
+265
View File
@@ -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: [