From 4137a6815c40c39c0ecdd0c609324b916e32e46a Mon Sep 17 00:00:00 2001 From: sihoo Date: Tue, 4 Aug 2026 14:01:50 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=90=EC=8A=B5=EC=8B=A4=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=EC=8B=9C=EA=B0=84=20=EC=A7=80=EC=A0=95=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20+=20=EC=95=B1=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=201.1.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 대시보드에서 자습실 출석시간(HH:MM) 지정 가능, 하교 시 자동 초기화 - 지정 시간 이전 태깅은 "출석 미완료", 이후는 시간 강조 표시로 구분 --- lib/screens/teacher_attendance_page.dart | 177 ++++++++++++++++++----- pubspec.yaml | 2 +- 2 files changed, 141 insertions(+), 38 deletions(-) diff --git a/lib/screens/teacher_attendance_page.dart b/lib/screens/teacher_attendance_page.dart index 9385796..1531efa 100644 --- a/lib/screens/teacher_attendance_page.dart +++ b/lib/screens/teacher_attendance_page.dart @@ -25,6 +25,7 @@ class _TeacherAttendancePageState extends State { List _logs = []; // 출석 로그 (/api/logs) Map _activeViolationsByStudentId = {}; // 무단반출 중인 학생 (/api/violations/active) String? _dismissedAt; // 오늘 가장 최근 하교 처리 시각 (/api/dismissal/latest). 이 시각 이후 기록만 "오늘 출석"으로 표시. + String? _attendanceTime; // 선생님이 지정한 자습실 출석시간 "HH:MM" (/api/settings/attendance-time). null이면 미설정. Timer? _timer; bool _isLoading = true; bool _isRefreshing = false; // 새로고침 버튼 클릭 시 잠깐 도는 표시용 @@ -56,12 +57,14 @@ class _TeacherAttendancePageState extends State { http.get(Uri.parse('$baseUrl/api/logs')), http.get(Uri.parse('$baseUrl/api/violations/active')), http.get(Uri.parse('$baseUrl/api/dismissal/latest')), + http.get(Uri.parse('$baseUrl/api/settings/attendance-time')), ]); final usersRes = results[0]; final logsRes = results[1]; final violationsRes = results[2]; final dismissalRes = results[3]; + final attendanceTimeRes = results[4]; if (usersRes.statusCode == 200 && logsRes.statusCode == 200) { final usersData = jsonDecode(utf8.decode(usersRes.bodyBytes)); @@ -81,11 +84,19 @@ class _TeacherAttendancePageState extends State { dismissedAt = dismissalData['dismissedAt']; } + String? attendanceTime; + if (attendanceTimeRes.statusCode == 200) { + final attendanceTimeData = + jsonDecode(utf8.decode(attendanceTimeRes.bodyBytes)); + attendanceTime = attendanceTimeData['attendanceTime']; + } + setState(() { _roster = usersData['users'] ?? []; _logs = logsData['logs'] ?? []; _activeViolationsByStudentId = activeViolations; _dismissedAt = dismissedAt; + _attendanceTime = attendanceTime; _isLoading = false; }); } else { @@ -158,6 +169,48 @@ class _TeacherAttendancePageState extends State { } } + /// ⏰ 자습실 출석시간(기준 시각)을 지정한다. 이 시각 이후 태깅한 학생만 "출석 완료"로 강조 표시된다. + Future _setAttendanceTime(String time) async { + try { + final response = await http.post( + Uri.parse('$baseUrl/api/settings/attendance-time'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({"time": time}), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result['message'] ?? '처리되었습니다.')), + ); + _fetchAll(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('❌ 출석시간 설정 실패: $e')), + ); + } + } + + Future _showAttendanceTimeDialog() async { + final TimeOfDay initial = _attendanceTime != null + ? TimeOfDay( + hour: int.parse(_attendanceTime!.split(':')[0]), + minute: int.parse(_attendanceTime!.split(':')[1]), + ) + : const TimeOfDay(hour: 19, minute: 0); + + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: initial, + helpText: '자습실 출석시간 지정', + ); + if (picked == null) return; + + final String formatted = + '${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}'; + _setAttendanceTime(formatted); + } + void _showAllowDialog(String studentId, String studentName) { final controller = TextEditingController(text: "5"); showDialog( @@ -290,18 +343,34 @@ class _TeacherAttendancePageState extends State { return result; } + /// ⏰ 태깅 시간과 지정된 자습실 출석시간을 비교해 'NONE' / 'PENDING' / 'COMPLETE'를 반환한다. + /// - NONE: 아직 태깅 안 함 + /// - PENDING: 태깅은 했지만 지정된 출석시간 이전이라 아직 "출석 완료"로 안 침 + /// - COMPLETE: 출석시간 미지정이거나, 지정된 출석시간 이후에 태깅함 + String _computeAttendanceStatus(String? checkInTime) { + if (checkInTime == null) return 'NONE'; + if (_attendanceTime == null) return 'COMPLETE'; + + final String todayStr = DateTime.now().toIso8601String().substring(0, 10); + final String cutoff = '$todayStr $_attendanceTime:00'; + return checkInTime.compareTo(cutoff) >= 0 ? 'COMPLETE' : 'PENDING'; + } + List> get _combinedStudentStatus { final checkIns = _todaysCheckInsByStudentId; return _roster.map((u) { final String id = u['id']?.toString() ?? ''; final checkIn = checkIns[id]; final violation = _activeViolationsByStudentId[id]; + final String attendanceStatus = _computeAttendanceStatus(checkIn?['time']); return { 'studentId': id, 'studentName': u['name']?.toString() ?? '', 'isCheckedIn': checkIn != null, 'pocketNumber': checkIn?['pocketNumber'], 'checkInTime': checkIn?['time'], + 'attendanceStatus': attendanceStatus, + 'isAttendanceComplete': attendanceStatus == 'COMPLETE', 'hasActiveViolation': violation != null, 'violationPocket': violation?['pocket_number'], 'violationTime': violation?['time'], @@ -312,9 +381,9 @@ class _TeacherAttendancePageState extends State { List> get _filteredStudents { final all = _combinedStudentStatus; if (_filterType == "CHECKED_IN") { - return all.where((s) => s['isCheckedIn'] == true).toList(); + return all.where((s) => s['isAttendanceComplete'] == true).toList(); } else if (_filterType == "ABSENT") { - return all.where((s) => s['isCheckedIn'] == false).toList(); + return all.where((s) => s['isAttendanceComplete'] == false).toList(); } return all; } @@ -323,7 +392,8 @@ class _TeacherAttendancePageState extends State { Widget build(BuildContext context) { final all = _combinedStudentStatus; final int totalCount = all.length; - final int checkedInCount = all.where((s) => s['isCheckedIn'] == true).length; + final int checkedInCount = + all.where((s) => s['isAttendanceComplete'] == true).length; final int absentCount = totalCount - checkedInCount; return Scaffold( @@ -351,6 +421,14 @@ class _TeacherAttendancePageState extends State { : const Icon(Icons.refresh_rounded), tooltip: '새로고침', ), + TextButton.icon( + onPressed: _showAttendanceTimeDialog, + icon: const Icon(Icons.access_time_rounded, color: Colors.white), + label: Text( + _attendanceTime != null ? '출석시간 $_attendanceTime' : '자습실 출석시간 설정', + style: const TextStyle(color: Colors.white), + ), + ), TextButton.icon( onPressed: _showPermissionWindowDialog, icon: const Icon(Icons.timer_outlined, color: Colors.white), @@ -406,6 +484,14 @@ class _TeacherAttendancePageState extends State { final student = _filteredStudents[index]; final bool isCheckedIn = student['isCheckedIn']; final bool hasViolation = student['hasActiveViolation'] == true; + final bool isPending = student['attendanceStatus'] == 'PENDING'; + final bool isComplete = student['attendanceStatus'] == 'COMPLETE'; + final bool emphasizeTime = isComplete && _attendanceTime != null; + final Color statusColor = hasViolation + ? Colors.red + : (isPending + ? Colors.orange + : (isComplete ? Colors.blue : Colors.red)); return Container( margin: const EdgeInsets.symmetric( horizontal: 24, @@ -434,21 +520,18 @@ class _TeacherAttendancePageState extends State { Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: (hasViolation - ? Colors.red - : (isCheckedIn ? Colors.blue : Colors.red)) - .withValues(alpha: 0.1), + color: statusColor.withValues(alpha: 0.1), shape: BoxShape.circle, ), child: Icon( hasViolation ? Icons.warning_amber_rounded - : (isCheckedIn - ? Icons.check_circle_rounded - : Icons.error_rounded), - color: hasViolation - ? Colors.red - : (isCheckedIn ? Colors.blue : Colors.red), + : (isPending + ? Icons.hourglass_bottom_rounded + : (isComplete + ? Icons.check_circle_rounded + : Icons.error_rounded)), + color: statusColor, size: 24, ), ), @@ -468,17 +551,21 @@ class _TeacherAttendancePageState extends State { Text( hasViolation ? '🚨 무단반출 감지! (${student['violationTime']})' - : (isCheckedIn - ? '학번: ${student['studentId']} | 제출시간: ${student['checkInTime']}' - : '학번: ${student['studentId']} | 미제출'), + : (isPending + ? '학번: ${student['studentId']} | ⏳ 출석 미완료 (제출: ${student['checkInTime']})' + : (isComplete + ? '학번: ${student['studentId']} | 제출시간: ${student['checkInTime']}' + : '학번: ${student['studentId']} | 미제출')), style: TextStyle( color: hasViolation ? Colors.red[700] - : (isCheckedIn - ? Colors.grey[600] - : Colors.red[400]), - fontSize: 12, - fontWeight: hasViolation + : (isPending + ? Colors.orange[800] + : (isComplete + ? Colors.grey[600] + : Colors.red[400])), + fontSize: emphasizeTime ? 14 : 12, + fontWeight: (hasViolation || emphasizeTime) ? FontWeight.bold : FontWeight.normal, ), @@ -621,23 +708,34 @@ class _TeacherAttendancePageState extends State { final bool isCheckedIn = student['isCheckedIn']; final bool hasViolation = student['hasActiveViolation'] == true; + final bool isPending = + student['attendanceStatus'] == 'PENDING'; + final bool isComplete = + student['attendanceStatus'] == 'COMPLETE'; + final bool emphasizeTime = + isComplete && _attendanceTime != null; + final Color statusColor = hasViolation + ? Colors.red + : (isPending + ? Colors.orange + : (isComplete ? Colors.blue : Colors.red)); return DataRow( color: hasViolation ? WidgetStateProperty.all(Colors.red[50]) - : null, + : (isPending + ? WidgetStateProperty.all(Colors.orange[50]) + : null), cells: [ DataCell( Icon( hasViolation ? Icons.warning_amber_rounded - : (isCheckedIn - ? Icons.check_circle_rounded - : Icons.error_rounded), - color: hasViolation - ? Colors.red - : (isCheckedIn - ? Colors.blue - : Colors.red), + : (isPending + ? Icons.hourglass_bottom_rounded + : (isComplete + ? Icons.check_circle_rounded + : Icons.error_rounded)), + color: statusColor, size: 20, ), ), @@ -654,16 +752,21 @@ class _TeacherAttendancePageState extends State { Text( hasViolation ? '🚨 무단반출 (${student['violationTime']})' - : (isCheckedIn - ? '${student['checkInTime']}' - : '미제출'), + : (isPending + ? '⏳ 출석 미완료 (제출: ${student['checkInTime']})' + : (isComplete + ? '${student['checkInTime']}' + : '미제출')), style: TextStyle( color: hasViolation ? Colors.red[700] - : (isCheckedIn - ? Colors.grey[700] - : Colors.red[400]), - fontWeight: hasViolation + : (isPending + ? Colors.orange[800] + : (isComplete + ? Colors.grey[700] + : Colors.red[400])), + fontSize: emphasizeTime ? 15 : 14, + fontWeight: (hasViolation || emphasizeTime) ? FontWeight.bold : FontWeight.normal, ), diff --git a/pubspec.yaml b/pubspec.yaml index b43c847..3534db6 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.0.0+1 +version: 1.1.1+2 environment: sdk: ^3.12.2