선생님 대시보드 학년별 카테고리 분류 + 학생 계정 관리 버그 수정

- 실시간 출석 현황을 학년별 섹션으로 묶어서 표시 (백엔드에 grade 필드 추가)
- 학생 계정 추가 폼에 학년 선택 드롭다운 추가
- 학생 계정 생성/삭제가 실제로는 존재하지 않는 엔드포인트를 호출하던
  버그 수정 (/api/users/register-student, /api/users/delete/{id} →
  /api/users/create, /api/users/delete)
- 학생 대시보드(개발자 모드) 카드 그리드가 넓은 화면에서 과도하게
  커지던 레이아웃 버그 수정, 웹은 5열/폰은 2열로 반응형 처리
- 버전 1.2.0+6

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 16:29:40 +09:00
co-authored by Claude Sonnet 5
parent 9354e2998a
commit d1c59c521b
6 changed files with 278 additions and 152 deletions
+46 -10
View File
@@ -14,10 +14,14 @@ final RegExp _logNamePattern = RegExp(r'^(.*?)\s*\(([^)]*)\)$');
class TeacherAttendanceController extends ChangeNotifier { class TeacherAttendanceController extends ChangeNotifier {
List<dynamic> _roster = []; // 전체 학생 명단 (/api/users) List<dynamic> _roster = []; // 전체 학생 명단 (/api/users)
List<dynamic> _logs = []; // 출석 로그 (/api/logs) List<dynamic> _logs = []; // 출석 로그 (/api/logs)
Map<String, dynamic> _activeViolationsByStudentId = {}; // 무단반출 중인 학생 (/api/violations/active) Map<String, dynamic> _activeViolationsByStudentId =
String? _dismissedAt; // 오늘 가장 최근 하교 처리 시각 (/api/dismissal/latest). 이 시각 이후 기록만 "오늘 출석"으로 표시. {}; // 무단반출 중인 학생 (/api/violations/active)
String? _attendanceTime; // 선생님이 지정한 자습실 출석시간 "HH:MM" (/api/settings/attendance-time). null이면 미설정. String?
String? _globalPermissionUntil; // 하교(12시간)/반출허용시간설정으로 전체 반출이 허용된 경우 그 만료 시각 (/api/permissions/status). null이면 비활성. _dismissedAt; // 오늘 가장 최근 하교 처리 시각 (/api/dismissal/latest). 이 시각 이후 기록만 "오늘 출석"으로 표시.
String?
_attendanceTime; // 선생님이 지정한 자습실 출석시간 "HH:MM" (/api/settings/attendance-time). null이면 미설정.
String?
_globalPermissionUntil; // 하교(12시간)/반출허용시간설정으로 전체 반출이 허용된 경우 그 만료 시각 (/api/permissions/status). null이면 비활성.
Timer? _timer; Timer? _timer;
bool _isLoading = true; bool _isLoading = true;
bool _isRefreshing = false; // 새로고침 버튼 클릭 시 잠깐 도는 표시용 bool _isRefreshing = false; // 새로고침 버튼 클릭 시 잠깐 도는 표시용
@@ -81,7 +85,9 @@ class TeacherAttendanceController extends ChangeNotifier {
Map<String, dynamic> activeViolations = {}; Map<String, dynamic> activeViolations = {};
if (violationsRes.statusCode == 200) { if (violationsRes.statusCode == 200) {
final violationsData = jsonDecode(utf8.decode(violationsRes.bodyBytes)); final violationsData = jsonDecode(
utf8.decode(violationsRes.bodyBytes),
);
for (final v in (violationsData['violations'] ?? [])) { for (final v in (violationsData['violations'] ?? [])) {
activeViolations[v['student_id'].toString()] = v; activeViolations[v['student_id'].toString()] = v;
} }
@@ -95,15 +101,17 @@ class TeacherAttendanceController extends ChangeNotifier {
String? attendanceTime; String? attendanceTime;
if (attendanceTimeRes.statusCode == 200) { if (attendanceTimeRes.statusCode == 200) {
final attendanceTimeData = final attendanceTimeData = jsonDecode(
jsonDecode(utf8.decode(attendanceTimeRes.bodyBytes)); utf8.decode(attendanceTimeRes.bodyBytes),
);
attendanceTime = attendanceTimeData['attendanceTime']; attendanceTime = attendanceTimeData['attendanceTime'];
} }
String? globalPermissionUntil; String? globalPermissionUntil;
if (permissionStatusRes.statusCode == 200) { if (permissionStatusRes.statusCode == 200) {
final permissionStatusData = final permissionStatusData = jsonDecode(
jsonDecode(utf8.decode(permissionStatusRes.bodyBytes)); utf8.decode(permissionStatusRes.bodyBytes),
);
if (permissionStatusData['active'] == true) { if (permissionStatusData['active'] == true) {
globalPermissionUntil = permissionStatusData['permittedUntil']; globalPermissionUntil = permissionStatusData['permittedUntil'];
} }
@@ -272,10 +280,13 @@ class TeacherAttendanceController extends ChangeNotifier {
final String id = u['id']?.toString() ?? ''; final String id = u['id']?.toString() ?? '';
final checkIn = checkIns[id]; final checkIn = checkIns[id];
final violation = _activeViolationsByStudentId[id]; final violation = _activeViolationsByStudentId[id];
final String attendanceStatus = _computeAttendanceStatus(checkIn?['time']); final String attendanceStatus = _computeAttendanceStatus(
checkIn?['time'],
);
return { return {
'studentId': id, 'studentId': id,
'studentName': u['name']?.toString() ?? '', 'studentName': u['name']?.toString() ?? '',
'grade': u['grade'] as int?,
'isCheckedIn': checkIn != null, 'isCheckedIn': checkIn != null,
'pocketNumber': checkIn?['pocketNumber'], 'pocketNumber': checkIn?['pocketNumber'],
'checkInTime': checkIn?['time'], 'checkInTime': checkIn?['time'],
@@ -298,4 +309,29 @@ class TeacherAttendanceController extends ChangeNotifier {
} }
return all; return all;
} }
/// 🏫 [학년별 카테고리] filteredStudents를 학년(1~3학년) 순서로 묶는다.
/// 학년이 아직 지정 안 된 학생은 맨 뒤 "미배정" 그룹으로 모인다.
List<MapEntry<String, List<Map<String, dynamic>>>> get studentsByGrade {
final Map<int, List<Map<String, dynamic>>> byGrade = {};
final List<Map<String, dynamic>> ungraded = [];
for (final student in filteredStudents) {
final int? grade = student['grade'] as int?;
if (grade == null) {
ungraded.add(student);
} else {
byGrade.putIfAbsent(grade, () => []).add(student);
}
}
final sortedGrades = byGrade.keys.toList()..sort();
final groups = <MapEntry<String, List<Map<String, dynamic>>>>[
for (final grade in sortedGrades) MapEntry('$grade학년', byGrade[grade]!),
];
if (ungraded.isNotEmpty) {
groups.add(MapEntry('미배정', ungraded));
}
return groups;
}
} }
@@ -13,11 +13,12 @@ class TeacherStudentManagementController extends ChangeNotifier {
required String studentId, required String studentId,
required String name, required String name,
required String password, required String password,
int? grade,
}) async { }) async {
_isWorking = true; _isWorking = true;
notifyListeners(); notifyListeners();
try { try {
final url = Uri.parse('$baseUrl/api/users/register-student'); final url = Uri.parse('$baseUrl/api/users/create');
final response = await http.post( final response = await http.post(
url, url,
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json"},
@@ -25,6 +26,7 @@ class TeacherStudentManagementController extends ChangeNotifier {
"studentId": studentId, "studentId": studentId,
"name": name, "name": name,
"password": password, "password": password,
"grade": grade,
}), }),
); );
final result = jsonDecode(utf8.decode(response.bodyBytes)); final result = jsonDecode(utf8.decode(response.bodyBytes));
@@ -48,9 +50,13 @@ class TeacherStudentManagementController extends ChangeNotifier {
) async { ) async {
_isWorking = true; _isWorking = true;
notifyListeners(); notifyListeners();
final url = Uri.parse('$baseUrl/api/users/delete/$studentId'); final url = Uri.parse('$baseUrl/api/users/delete');
try { try {
final response = await http.delete(url); final response = await http.delete(
url,
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": studentId}),
);
if (response.statusCode == 200) { if (response.statusCode == 200) {
final resData = jsonDecode(utf8.decode(response.bodyBytes)); final resData = jsonDecode(utf8.decode(response.bodyBytes));
return (true, '✅ ${resData['message']}'); return (true, '✅ ${resData['message']}');
+22 -5
View File
@@ -282,12 +282,21 @@ class _StudentDashboardState extends State<StudentDashboard> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Padding( LayoutBuilder(
builder: (context, constraints) {
// 🖥️ 웹(넓은 화면)은 한 줄에 5개씩, 폰(좁은 화면)은 기존 2개 그대로.
final bool isWide = constraints.maxWidth >= 800;
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isWide ? 1300 : 700,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0), padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: GridView.count( child: GridView.count(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2, crossAxisCount: isWide ? 5 : 2,
crossAxisSpacing: 16, crossAxisSpacing: 16,
mainAxisSpacing: 16, mainAxisSpacing: 16,
childAspectRatio: 0.95, childAspectRatio: 0.95,
@@ -308,7 +317,9 @@ class _StudentDashboardState extends State<StudentDashboard> {
onTap: widget.isDeviceMatched onTap: widget.isDeviceMatched
? () => _openPocketCheckIn(context) ? () => _openPocketCheckIn(context)
: () { : () {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
context,
).showSnackBar(
const SnackBar( const SnackBar(
content: Text( content: Text(
'🚨 대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.', '🚨 대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.',
@@ -358,7 +369,8 @@ class _StudentDashboardState extends State<StudentDashboard> {
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => const AdminDashboard(), builder: (context) =>
const AdminDashboard(),
), ),
), ),
), ),
@@ -393,13 +405,18 @@ class _StudentDashboardState extends State<StudentDashboard> {
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => const NfcTagWriterScreen(), builder: (context) =>
const NfcTagWriterScreen(),
), ),
), ),
), ),
], ],
), ),
), ),
),
);
},
),
const SizedBox(height: 32), const SizedBox(height: 32),
], ],
), ),
+69 -23
View File
@@ -230,7 +230,10 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
), ),
TextButton.icon( TextButton.icon(
onPressed: _showAttendanceTimeDialog, onPressed: _showAttendanceTimeDialog,
icon: const Icon(Icons.access_time_rounded, color: Colors.white), icon: const Icon(
Icons.access_time_rounded,
color: Colors.white,
),
label: Text( label: Text(
_controller.attendanceTime != null _controller.attendanceTime != null
? '출석시간 ${_controller.attendanceTime}' ? '출석시간 ${_controller.attendanceTime}'
@@ -289,35 +292,87 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
// 📱 모바일 레이아웃 (기존 카드 리스트) // 📱 모바일 레이아웃 (기존 카드 리스트)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
Widget _buildMobileBody(int total, int checkedIn, int absent) { Widget _buildMobileBody(int total, int checkedIn, int absent) {
final filteredStudents = _controller.filteredStudents; final groups = _controller.studentsByGrade;
return Column( return Column(
children: [ children: [
_buildSummaryCards(total, checkedIn, absent), _buildSummaryCards(total, checkedIn, absent),
_buildPermissionBanner(), _buildPermissionBanner(),
_buildFilterChips(), _buildFilterChips(),
Expanded( Expanded(
child: filteredStudents.isEmpty child: groups.isEmpty
? const Center( ? const Center(
child: Text( child: Text(
'해당하는 학생이 없습니다.', '해당하는 학생이 없습니다.',
style: TextStyle(color: Colors.grey), style: TextStyle(color: Colors.grey),
), ),
) )
: GridView.builder( : _buildGradeGroupedList(
groups,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
gridDelegate: ),
const SliverGridDelegateWithMaxCrossAxisExtent( ),
],
);
}
/// 🏫 학년별로 섹션 제목을 붙여 그리드를 이어붙인 목록. 모바일/데스크톱 공용.
Widget _buildGradeGroupedList(
List<MapEntry<String, List<Map<String, dynamic>>>> groups, {
required EdgeInsets padding,
}) {
return ListView.builder(
padding: padding,
itemCount: groups.length,
itemBuilder: (context, index) {
final group = groups[index];
return Padding(
padding: EdgeInsets.only(bottom: index == groups.length - 1 ? 0 : 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
group.key,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(width: 6),
Text(
'${group.value.length}명',
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
),
],
),
const SizedBox(height: 10),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 220, maxCrossAxisExtent: 220,
mainAxisSpacing: 12, mainAxisSpacing: 12,
crossAxisSpacing: 12, crossAxisSpacing: 12,
mainAxisExtent: 148, mainAxisExtent: 148,
), ),
itemCount: filteredStudents.length, itemCount: group.value.length,
itemBuilder: (context, index) => itemBuilder: (context, i) => _buildStudentTile(group.value[i]),
_buildStudentTile(filteredStudents[index]),
),
), ),
], ],
),
);
},
); );
} }
@@ -454,7 +509,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
// 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때) // 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
Widget _buildDesktopBody(int total, int checkedIn, int absent) { Widget _buildDesktopBody(int total, int checkedIn, int absent) {
final filteredStudents = _controller.filteredStudents; final groups = _controller.studentsByGrade;
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1100), constraints: const BoxConstraints(maxWidth: 1100),
@@ -511,25 +566,16 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
), ),
], ],
), ),
child: filteredStudents.isEmpty child: groups.isEmpty
? const Center( ? const Center(
child: Text( child: Text(
'해당하는 학생이 없습니다.', '해당하는 학생이 없습니다.',
style: TextStyle(color: Colors.grey), style: TextStyle(color: Colors.grey),
), ),
) )
: GridView.builder( : _buildGradeGroupedList(
groups,
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 220,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
mainAxisExtent: 148,
),
itemCount: filteredStudents.length,
itemBuilder: (context, index) =>
_buildStudentTile(filteredStudents[index]),
), ),
), ),
), ),
+22 -1
View File
@@ -21,6 +21,7 @@ class _TeacherStudentManagementPageState
final TextEditingController _addIdController = TextEditingController(); final TextEditingController _addIdController = TextEditingController();
final TextEditingController _addNameController = TextEditingController(); final TextEditingController _addNameController = TextEditingController();
final TextEditingController _addPwController = TextEditingController(); final TextEditingController _addPwController = TextEditingController();
int? _selectedGrade;
@override @override
void dispose() { void dispose() {
@@ -48,12 +49,14 @@ class _TeacherStudentManagementPageState
studentId: sId, studentId: sId,
name: sName, name: sName,
password: sPw, password: sPw,
grade: _selectedGrade,
); );
if (!mounted) return; if (!mounted) return;
if (success) { if (success) {
_addIdController.clear(); _addIdController.clear();
_addNameController.clear(); _addNameController.clear();
_addPwController.clear(); _addPwController.clear();
setState(() => _selectedGrade = null);
} }
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
@@ -184,7 +187,10 @@ class _TeacherStudentManagementPageState
const SizedBox(width: 8), const SizedBox(width: 8),
const Text( const Text(
'신규 학생 계정 추가', '신규 학생 계정 추가',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
), ),
], ],
), ),
@@ -230,6 +236,21 @@ class _TeacherStudentManagementPageState
prefixIcon: Icon(Icons.lock), 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), const SizedBox(height: 20),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.1.4+5 version: 1.2.0+6
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2