From d1c59c521bcc356e88464502f240d413815b49e1 Mon Sep 17 00:00:00 2001 From: sihoo Date: Fri, 28 Aug 2026 16:29:40 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=A0=EC=83=9D=EB=8B=98=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=ED=95=99=EB=85=84=EB=B3=84=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=B6=84=EB=A5=98=20+?= =?UTF-8?q?=20=ED=95=99=EC=83=9D=20=EA=B3=84=EC=A0=95=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실시간 출석 현황을 학년별 섹션으로 묶어서 표시 (백엔드에 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 --- .../teacher_attendance_controller.dart | 56 ++++- ...teacher_student_management_controller.dart | 12 +- lib/ui/student_dashboard.dart | 237 ++++++++++-------- lib/ui/teacher_attendance_page.dart | 100 ++++++-- lib/ui/teacher_student_management_page.dart | 23 +- pubspec.yaml | 2 +- 6 files changed, 278 insertions(+), 152 deletions(-) diff --git a/lib/function/teacher_attendance_controller.dart b/lib/function/teacher_attendance_controller.dart index 7ad443f..6250aa2 100644 --- a/lib/function/teacher_attendance_controller.dart +++ b/lib/function/teacher_attendance_controller.dart @@ -14,10 +14,14 @@ final RegExp _logNamePattern = RegExp(r'^(.*?)\s*\(([^)]*)\)$'); class TeacherAttendanceController extends ChangeNotifier { List _roster = []; // 전체 학생 명단 (/api/users) List _logs = []; // 출석 로그 (/api/logs) - Map _activeViolationsByStudentId = {}; // 무단반출 중인 학생 (/api/violations/active) - String? _dismissedAt; // 오늘 가장 최근 하교 처리 시각 (/api/dismissal/latest). 이 시각 이후 기록만 "오늘 출석"으로 표시. - String? _attendanceTime; // 선생님이 지정한 자습실 출석시간 "HH:MM" (/api/settings/attendance-time). null이면 미설정. - String? _globalPermissionUntil; // 하교(12시간)/반출허용시간설정으로 전체 반출이 허용된 경우 그 만료 시각 (/api/permissions/status). null이면 비활성. + Map _activeViolationsByStudentId = + {}; // 무단반출 중인 학생 (/api/violations/active) + String? + _dismissedAt; // 오늘 가장 최근 하교 처리 시각 (/api/dismissal/latest). 이 시각 이후 기록만 "오늘 출석"으로 표시. + String? + _attendanceTime; // 선생님이 지정한 자습실 출석시간 "HH:MM" (/api/settings/attendance-time). null이면 미설정. + String? + _globalPermissionUntil; // 하교(12시간)/반출허용시간설정으로 전체 반출이 허용된 경우 그 만료 시각 (/api/permissions/status). null이면 비활성. Timer? _timer; bool _isLoading = true; bool _isRefreshing = false; // 새로고침 버튼 클릭 시 잠깐 도는 표시용 @@ -81,7 +85,9 @@ class TeacherAttendanceController extends ChangeNotifier { Map activeViolations = {}; if (violationsRes.statusCode == 200) { - final violationsData = jsonDecode(utf8.decode(violationsRes.bodyBytes)); + final violationsData = jsonDecode( + utf8.decode(violationsRes.bodyBytes), + ); for (final v in (violationsData['violations'] ?? [])) { activeViolations[v['student_id'].toString()] = v; } @@ -95,15 +101,17 @@ class TeacherAttendanceController extends ChangeNotifier { String? attendanceTime; if (attendanceTimeRes.statusCode == 200) { - final attendanceTimeData = - jsonDecode(utf8.decode(attendanceTimeRes.bodyBytes)); + final attendanceTimeData = jsonDecode( + utf8.decode(attendanceTimeRes.bodyBytes), + ); attendanceTime = attendanceTimeData['attendanceTime']; } String? globalPermissionUntil; if (permissionStatusRes.statusCode == 200) { - final permissionStatusData = - jsonDecode(utf8.decode(permissionStatusRes.bodyBytes)); + final permissionStatusData = jsonDecode( + utf8.decode(permissionStatusRes.bodyBytes), + ); if (permissionStatusData['active'] == true) { globalPermissionUntil = permissionStatusData['permittedUntil']; } @@ -272,10 +280,13 @@ class TeacherAttendanceController extends ChangeNotifier { final String id = u['id']?.toString() ?? ''; final checkIn = checkIns[id]; final violation = _activeViolationsByStudentId[id]; - final String attendanceStatus = _computeAttendanceStatus(checkIn?['time']); + final String attendanceStatus = _computeAttendanceStatus( + checkIn?['time'], + ); return { 'studentId': id, 'studentName': u['name']?.toString() ?? '', + 'grade': u['grade'] as int?, 'isCheckedIn': checkIn != null, 'pocketNumber': checkIn?['pocketNumber'], 'checkInTime': checkIn?['time'], @@ -298,4 +309,29 @@ class TeacherAttendanceController extends ChangeNotifier { } return all; } + + /// 🏫 [학년별 카테고리] filteredStudents를 학년(1~3학년) 순서로 묶는다. + /// 학년이 아직 지정 안 된 학생은 맨 뒤 "미배정" 그룹으로 모인다. + List>>> get studentsByGrade { + final Map>> byGrade = {}; + final List> 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 = >>>[ + for (final grade in sortedGrades) MapEntry('$grade학년', byGrade[grade]!), + ]; + if (ungraded.isNotEmpty) { + groups.add(MapEntry('미배정', ungraded)); + } + return groups; + } } diff --git a/lib/function/teacher_student_management_controller.dart b/lib/function/teacher_student_management_controller.dart index 02ff12a..35f48e2 100644 --- a/lib/function/teacher_student_management_controller.dart +++ b/lib/function/teacher_student_management_controller.dart @@ -13,11 +13,12 @@ class TeacherStudentManagementController extends ChangeNotifier { required String studentId, required String name, required String password, + int? grade, }) async { _isWorking = true; notifyListeners(); try { - final url = Uri.parse('$baseUrl/api/users/register-student'); + final url = Uri.parse('$baseUrl/api/users/create'); final response = await http.post( url, headers: {"Content-Type": "application/json"}, @@ -25,6 +26,7 @@ class TeacherStudentManagementController extends ChangeNotifier { "studentId": studentId, "name": name, "password": password, + "grade": grade, }), ); final result = jsonDecode(utf8.decode(response.bodyBytes)); @@ -48,9 +50,13 @@ class TeacherStudentManagementController extends ChangeNotifier { ) async { _isWorking = true; notifyListeners(); - final url = Uri.parse('$baseUrl/api/users/delete/$studentId'); + final url = Uri.parse('$baseUrl/api/users/delete'); 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) { final resData = jsonDecode(utf8.decode(response.bodyBytes)); return (true, '✅ ${resData['message']}'); diff --git a/lib/ui/student_dashboard.dart b/lib/ui/student_dashboard.dart index 6f91fa6..fac5f0f 100644 --- a/lib/ui/student_dashboard.dart +++ b/lib/ui/student_dashboard.dart @@ -282,123 +282,140 @@ class _StudentDashboardState extends State { ), ), const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: GridView.count( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - crossAxisCount: 2, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - childAspectRatio: 0.95, - children: [ - // 🆕 [변경 지점] 기기가 일치하면 정상 활성화, 일치하지 않으면 자물쇠Lock 처리 - if (isDeveloper || widget.studentId != "2061") - _buildModernCard( - icon: widget.isDeviceMatched - ? Icons.contactless_rounded - : Icons.lock_rounded, - title: 'NFC 태그', - subtitle: widget.isDeviceMatched - ? '출석 및 폰 수거 완료' - : '⚠️ 본인 인증 기기 전용', - color: widget.isDeviceMatched - ? Colors.blue - : Colors.grey[400]!, - onTap: widget.isDeviceMatched - ? () => _openPocketCheckIn(context) - : () { + 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), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: isWide ? 5 : 2, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: 0.95, + children: [ + // 🆕 [변경 지점] 기기가 일치하면 정상 활성화, 일치하지 않으면 자물쇠Lock 처리 + if (isDeveloper || widget.studentId != "2061") + _buildModernCard( + icon: widget.isDeviceMatched + ? Icons.contactless_rounded + : Icons.lock_rounded, + title: 'NFC 태그', + subtitle: widget.isDeviceMatched + ? '출석 및 폰 수거 완료' + : '⚠️ 본인 인증 기기 전용', + color: widget.isDeviceMatched + ? Colors.blue + : Colors.grey[400]!, + onTap: widget.isDeviceMatched + ? () => _openPocketCheckIn(context) + : () { + ScaffoldMessenger.of( + context, + ).showSnackBar( + const SnackBar( + content: Text( + '🚨 대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.', + ), + ), + ); + }, + isActionButton: widget.isDeviceMatched, + isLoading: _controller.isLoading, + ), + + // 🆕 [추가 지점] 기기 일치 여부 상관없이 태블릿에서도 누구나 확인 가능한 학교 상황판 카드 + _buildModernCard( + icon: Icons.fastfood_rounded, + title: '실시간 학교 상황', + subtitle: '급식실 줄 & 매점 재고 확인', + color: Colors.orange[700]!, + onTap: () { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text( - '🚨 대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.', - ), + content: Text('🍔 실시간 학교 상황 페이지로 이동합니다.'), ), ); }, - isActionButton: widget.isDeviceMatched, - isLoading: _controller.isLoading, - ), + ), - // 🆕 [추가 지점] 기기 일치 여부 상관없이 태블릿에서도 누구나 확인 가능한 학교 상황판 카드 - _buildModernCard( - icon: Icons.fastfood_rounded, - title: '실시간 학교 상황', - subtitle: '급식실 줄 & 매점 재고 확인', - color: Colors.orange[700]!, - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('🍔 실시간 학교 상황 페이지로 이동합니다.'), - ), - ); - }, + if (isDeveloper) + _buildModernCard( + icon: Icons.monitor_heart_rounded, + title: '실시간 현황', + subtitle: '교사용 수거 모니터링', + color: Colors.teal, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const TeacherAttendancePage(), + ), + ), + ), + if (isDeveloper) + _buildModernCard( + icon: Icons.terminal_rounded, + title: '서버 DB 제어', + subtitle: '시스템 원격 초기화', + color: Colors.amber[800]!, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const AdminDashboard(), + ), + ), + ), + if (isDeveloper) + _buildModernCard( + icon: Icons.add_moderator_rounded, + title: '학생 계정 관리', + subtitle: 'UUID 리셋 및 승인', + color: Colors.purple, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const StudentManagementScreen(), + ), + ), + ), + if (isDeveloper) + _buildModernCard( + icon: Icons.delete_sweep_rounded, + title: '계정 강제 삭제', + subtitle: '학생 및 교사 DB 삭제', + color: Colors.red[600]!, + onTap: () => _showDeleteUserDialog(context), + ), + if (isDeveloper) + _buildModernCard( + icon: Icons.edit_note_rounded, + title: 'NFC 태그 쓰기', + subtitle: '주머니 스티커 초기 설정', + color: Colors.deepPurple, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const NfcTagWriterScreen(), + ), + ), + ), + ], + ), + ), ), - - if (isDeveloper) - _buildModernCard( - icon: Icons.monitor_heart_rounded, - title: '실시간 현황', - subtitle: '교사용 수거 모니터링', - color: Colors.teal, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - const TeacherAttendancePage(), - ), - ), - ), - if (isDeveloper) - _buildModernCard( - icon: Icons.terminal_rounded, - title: '서버 DB 제어', - subtitle: '시스템 원격 초기화', - color: Colors.amber[800]!, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const AdminDashboard(), - ), - ), - ), - if (isDeveloper) - _buildModernCard( - icon: Icons.add_moderator_rounded, - title: '학생 계정 관리', - subtitle: 'UUID 리셋 및 승인', - color: Colors.purple, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - const StudentManagementScreen(), - ), - ), - ), - if (isDeveloper) - _buildModernCard( - icon: Icons.delete_sweep_rounded, - title: '계정 강제 삭제', - subtitle: '학생 및 교사 DB 삭제', - color: Colors.red[600]!, - onTap: () => _showDeleteUserDialog(context), - ), - if (isDeveloper) - _buildModernCard( - icon: Icons.edit_note_rounded, - title: 'NFC 태그 쓰기', - subtitle: '주머니 스티커 초기 설정', - color: Colors.deepPurple, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const NfcTagWriterScreen(), - ), - ), - ), - ], - ), + ); + }, ), const SizedBox(height: 32), ], diff --git a/lib/ui/teacher_attendance_page.dart b/lib/ui/teacher_attendance_page.dart index df9bdf2..125eb10 100644 --- a/lib/ui/teacher_attendance_page.dart +++ b/lib/ui/teacher_attendance_page.dart @@ -230,7 +230,10 @@ class _TeacherAttendancePageState extends State { ), TextButton.icon( onPressed: _showAttendanceTimeDialog, - icon: const Icon(Icons.access_time_rounded, color: Colors.white), + icon: const Icon( + Icons.access_time_rounded, + color: Colors.white, + ), label: Text( _controller.attendanceTime != null ? '출석시간 ${_controller.attendanceTime}' @@ -289,38 +292,90 @@ class _TeacherAttendancePageState extends State { // 📱 모바일 레이아웃 (기존 카드 리스트) // ----------------------------------------------------------------------- Widget _buildMobileBody(int total, int checkedIn, int absent) { - final filteredStudents = _controller.filteredStudents; + final groups = _controller.studentsByGrade; return Column( children: [ _buildSummaryCards(total, checkedIn, absent), _buildPermissionBanner(), _buildFilterChips(), Expanded( - child: filteredStudents.isEmpty + child: groups.isEmpty ? const Center( child: Text( '해당하는 학생이 없습니다.', style: TextStyle(color: Colors.grey), ), ) - : GridView.builder( + : _buildGradeGroupedList( + groups, padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), - gridDelegate: - const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 220, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - mainAxisExtent: 148, - ), - itemCount: filteredStudents.length, - itemBuilder: (context, index) => - _buildStudentTile(filteredStudents[index]), ), ), ], ); } + /// 🏫 학년별로 섹션 제목을 붙여 그리드를 이어붙인 목록. 모바일/데스크톱 공용. + Widget _buildGradeGroupedList( + List>>> 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, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + mainAxisExtent: 148, + ), + itemCount: group.value.length, + itemBuilder: (context, i) => _buildStudentTile(group.value[i]), + ), + ], + ), + ); + }, + ); + } + /// 🀄 학생 한 명을 "한눈에 보기" 타일로 그린다. 모바일/데스크톱 그리드에서 공용으로 쓴다. Widget _buildStudentTile(Map student) { final bool isCheckedIn = student['isCheckedIn']; @@ -454,7 +509,7 @@ class _TeacherAttendancePageState extends State { // 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때) // ----------------------------------------------------------------------- Widget _buildDesktopBody(int total, int checkedIn, int absent) { - final filteredStudents = _controller.filteredStudents; + final groups = _controller.studentsByGrade; return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 1100), @@ -511,25 +566,16 @@ class _TeacherAttendancePageState extends State { ), ], ), - child: filteredStudents.isEmpty + child: groups.isEmpty ? const Center( child: Text( '해당하는 학생이 없습니다.', style: TextStyle(color: Colors.grey), ), ) - : GridView.builder( + : _buildGradeGroupedList( + groups, 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]), ), ), ), diff --git a/lib/ui/teacher_student_management_page.dart b/lib/ui/teacher_student_management_page.dart index 1d91937..6bbc10a 100644 --- a/lib/ui/teacher_student_management_page.dart +++ b/lib/ui/teacher_student_management_page.dart @@ -21,6 +21,7 @@ class _TeacherStudentManagementPageState final TextEditingController _addIdController = TextEditingController(); final TextEditingController _addNameController = TextEditingController(); final TextEditingController _addPwController = TextEditingController(); + int? _selectedGrade; @override void dispose() { @@ -48,12 +49,14 @@ class _TeacherStudentManagementPageState 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, @@ -184,7 +187,10 @@ class _TeacherStudentManagementPageState const SizedBox(width: 8), 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), ), ), + const SizedBox(height: 12), + DropdownButtonFormField( + 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, diff --git a/pubspec.yaml b/pubspec.yaml index b8f7a6a..9f3c497 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 # 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. -version: 1.1.4+5 +version: 1.2.0+6 environment: sdk: ^3.12.2