// ๐Ÿ“‹ ์‹ค์‹œ๊ฐ„ ์ถœ์„ ํ˜„ํ™ฉ ํ™”๋ฉด์˜ ๊ธฐ๋Šฅ(์ƒํƒœ/์„œ๋ฒ„ ํ†ต์‹ /ํŒŒ์ƒ ๋กœ์ง) ๋‹ด๋‹น ์ปจํŠธ๋กค๋Ÿฌ. // UI(lib/ui/teacher_attendance_page.dart)๋Š” ์ด ์ปจํŠธ๋กค๋Ÿฌ๋ฅผ ๊ตฌ๋…ํ•ด์„œ ํ™”๋ฉด๋งŒ ๊ทธ๋ฆฐ๋‹ค. // ์ด ํŒŒ์ผ์—๋Š” ์œ„์ ฏ/BuildContext/๋‹ค์ด์–ผ๋กœ๊ทธ ์ฝ”๋“œ๊ฐ€ ์—†์–ด์•ผ ํ•œ๋‹ค โ€” ์ „๋ถ€ UI ํŒŒ์ผ ๋ชซ. import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../config.dart'; // ๋กœ๊ทธ์˜ 'name' ์ปฌ๋Ÿผ์€ ๋ฐฑ์—”๋“œ์—์„œ "ํ•™์ƒ์ด๋ฆ„ (์ฃผ๋จธ๋‹ˆ์ •๋ณด)" ํ˜•ํƒœ๋กœ ํ•ฉ์ณ์ ธ ์ €์žฅ๋˜์–ด ์žˆ์–ด์„œ // ์ด๋ฆ„๊ณผ ์ฃผ๋จธ๋‹ˆ ๋ฒˆํ˜ธ๋ฅผ ๋ถ„๋ฆฌํ•ด์„œ ๋ณด์—ฌ์ฃผ๋ ค๋ฉด ํด๋ผ์ด์–ธํŠธ์—์„œ ํŒŒ์‹ฑํ•ด์•ผ ํ•œ๋‹ค. 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์ด๋ฉด ๋น„ํ™œ์„ฑ. Timer? _timer; bool _isLoading = true; bool _isRefreshing = false; // ์ƒˆ๋กœ๊ณ ์นจ ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ ์ž ๊น ๋„๋Š” ํ‘œ์‹œ์šฉ String _filterType = "ALL"; // "ALL", "CHECKED_IN", "ABSENT" bool _disposed = false; // ---------------------------------------------------------------- // UI๊ฐ€ ์ฝ๋Š” ๊ณต๊ฐœ ์ƒํƒœ // ---------------------------------------------------------------- bool get isLoading => _isLoading; bool get isRefreshing => _isRefreshing; String get filterType => _filterType; String? get attendanceTime => _attendanceTime; String? get globalPermissionUntil => _globalPermissionUntil; void _safeNotify() { if (!_disposed) notifyListeners(); } void init() { fetchAll(); _timer = Timer.periodic(const Duration(seconds: 3), (timer) => fetchAll()); } @override void dispose() { _disposed = true; _timer?.cancel(); super.dispose(); } Future manualRefresh() async { _isRefreshing = true; _safeNotify(); await fetchAll(); _isRefreshing = false; _safeNotify(); } Future fetchAll() async { try { final results = await Future.wait([ http.get(Uri.parse('$baseUrl/api/users')), 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')), http.get(Uri.parse('$baseUrl/api/permissions/status')), ]); final usersRes = results[0]; final logsRes = results[1]; final violationsRes = results[2]; final dismissalRes = results[3]; final attendanceTimeRes = results[4]; final permissionStatusRes = results[5]; if (usersRes.statusCode == 200 && logsRes.statusCode == 200) { final usersData = jsonDecode(utf8.decode(usersRes.bodyBytes)); final logsData = jsonDecode(utf8.decode(logsRes.bodyBytes)); Map activeViolations = {}; if (violationsRes.statusCode == 200) { final violationsData = jsonDecode(utf8.decode(violationsRes.bodyBytes)); for (final v in (violationsData['violations'] ?? [])) { activeViolations[v['student_id'].toString()] = v; } } String? dismissedAt; if (dismissalRes.statusCode == 200) { final dismissalData = jsonDecode(utf8.decode(dismissalRes.bodyBytes)); dismissedAt = dismissalData['dismissedAt']; } String? attendanceTime; if (attendanceTimeRes.statusCode == 200) { final attendanceTimeData = jsonDecode(utf8.decode(attendanceTimeRes.bodyBytes)); attendanceTime = attendanceTimeData['attendanceTime']; } String? globalPermissionUntil; if (permissionStatusRes.statusCode == 200) { final permissionStatusData = jsonDecode(utf8.decode(permissionStatusRes.bodyBytes)); if (permissionStatusData['active'] == true) { globalPermissionUntil = permissionStatusData['permittedUntil']; } } _roster = usersData['users'] ?? []; _logs = logsData['logs'] ?? []; _activeViolationsByStudentId = activeViolations; _dismissedAt = dismissedAt; _attendanceTime = attendanceTime; _globalPermissionUntil = globalPermissionUntil; _isLoading = false; _safeNotify(); } else { _isLoading = false; _safeNotify(); } } catch (e) { _isLoading = false; _safeNotify(); } } /// ๐Ÿซ ํ•˜๊ต ์ฒ˜๋ฆฌ: ์ „์ฒด ๋ฐ˜์ถœ ํ—ˆ์šฉ + ์˜ค๋Š˜ ์ถœ์„ ํ‘œ์‹œ ๊ธฐ์ค€์„ ์„ ์ง€๊ธˆ ์‹œ๊ฐ์œผ๋กœ ์˜ฎ๊ธด๋‹ค. Future dismissAll() async { try { final response = await http.post(Uri.parse('$baseUrl/api/dismiss')); final result = jsonDecode(utf8.decode(response.bodyBytes)); await fetchAll(); return result['message'] ?? 'ํ•˜๊ต ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'; } catch (e) { return 'โŒ ํ•˜๊ต ์ฒ˜๋ฆฌ ์‹คํŒจ: $e'; } } /// ๐Ÿšจ ์„ ์ƒ๋‹˜์ด ํŠน์ • ํ•™์ƒ์—๊ฒŒ ์ง€๊ธˆ๋ถ€ํ„ฐ N๋ถ„๊ฐ„ ๋ฐ˜์ถœ์„ ํ—ˆ์šฉํ•œ๋‹ค. Future allowRemoval(String studentId, int minutes) async { try { final response = await http.post( Uri.parse('$baseUrl/api/violations/allow'), headers: {"Content-Type": "application/json"}, body: jsonEncode({"studentId": studentId, "minutes": minutes}), ); final result = jsonDecode(utf8.decode(response.bodyBytes)); await fetchAll(); return result['message'] ?? '์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'; } catch (e) { return 'โŒ ๋ฐ˜์ถœ ํ—ˆ์šฉ ์‹คํŒจ: $e'; } } /// โฐ ์ง€๊ธˆ๋ถ€ํ„ฐ N๋ถ„๊ฐ„ ์ „์ฒด ํ•™์ƒ์˜ ๋ฐ˜์ถœ์„ ์ž๋™์œผ๋กœ ํ—ˆ์šฉํ•œ๋‹ค (์‰ฌ๋Š”์‹œ๊ฐ„ ๋“ฑ). Future setPermissionWindow(int minutes) async { try { final response = await http.post( Uri.parse('$baseUrl/api/permissions/window'), headers: {"Content-Type": "application/json"}, body: jsonEncode({"minutes": minutes}), ); final result = jsonDecode(utf8.decode(response.bodyBytes)); await fetchAll(); return result['message'] ?? '์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'; } catch (e) { return 'โŒ ์„ค์ • ์‹คํŒจ: $e'; } } /// โฐ ์ž์Šต์‹ค ์ถœ์„์‹œ๊ฐ„(๊ธฐ์ค€ ์‹œ๊ฐ)์„ ์ง€์ •ํ•œ๋‹ค. ์ด ์‹œ๊ฐ ์ดํ›„ ํƒœ๊น…ํ•œ ํ•™์ƒ๋งŒ "์ถœ์„ ์™„๋ฃŒ"๋กœ ๊ฐ•์กฐ ํ‘œ์‹œ๋œ๋‹ค. 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)); await fetchAll(); return result['message'] ?? '์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'; } catch (e) { return 'โŒ ์ถœ์„์‹œ๊ฐ„ ์„ค์ • ์‹คํŒจ: $e'; } } /// ๐Ÿงช ํ…Œ์ŠคํŠธ์šฉ: ํ•˜๊ต(12์‹œ๊ฐ„)/๋ฐ˜์ถœ ํ—ˆ์šฉ ์‹œ๊ฐ„ ์„ค์ • ๋“ฑ์œผ๋กœ ์ผœ์ ธ ์žˆ๋Š” ํ—ˆ์šฉ ์‹œ๊ฐ„๋Œ€๋ฅผ ์ฆ‰์‹œ ํ•ด์ œํ•œ๋‹ค. Future resetTestPermissions() async { try { final response = await http.post( Uri.parse('$baseUrl/api/debug/reset-permissions'), ); final result = jsonDecode(utf8.decode(response.bodyBytes)); await fetchAll(); return result['message'] ?? '์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'; } catch (e) { return 'โŒ ์ดˆ๊ธฐํ™” ์‹คํŒจ: $e'; } } void setFilter(String type) { _filterType = type; _safeNotify(); } // ---------------------------------------------------------------- // ํŒŒ์ƒ ๋กœ์ง (์ถœ์„ ์ƒํƒœ ๊ณ„์‚ฐ) // ---------------------------------------------------------------- /// "์˜ค๋Š˜ ์ถœ์„"์˜ ๊ธฐ์ค€์„ . ํ•˜๊ต ์ฒ˜๋ฆฌ๋ฅผ ํ–ˆ๋‹ค๋ฉด ๊ทธ ์‹œ๊ฐ ์ดํ›„, ์•ˆ ํ–ˆ๋‹ค๋ฉด ์˜ค๋Š˜ ์ž์ •๋ถ€ํ„ฐ. Map> get _todaysCheckInsByStudentId { // "YYYY-MM-DD HH:MM:SS" ํ˜•ํƒœ์˜ ๋ฌธ์ž์—ด๋ผ๋ฆฌ๋Š” ๊ทธ๋Œ€๋กœ ๋น„๊ตํ•ด๋„ ์‹œ๊ฐ„ ์ˆœ์„œ๊ฐ€ ๋งž๋Š”๋‹ค. final String cutoff = _dismissedAt ?? '${DateTime.now().toIso8601String().substring(0, 10)} 00:00:00'; final Map> result = {}; for (final log in _logs) { final String time = log['time']?.toString() ?? ''; if (time.isEmpty || time.compareTo(cutoff) <= 0) { continue; // ํ•˜๊ต ์ฒ˜๋ฆฌ ์‹œ๊ฐ(๋˜๋Š” ์˜ค๋Š˜ ์ž์ •) ์ด์ „ ๊ธฐ๋ก์ด๋ฉด ๋ฌด์‹œ } final String studentId = log['student_id']?.toString() ?? ''; if (studentId.isEmpty) continue; // ์ด๋ฏธ ๋” ์ตœ์‹  ๊ธฐ๋ก์„ ์ฐพ์•˜๋‹ค๋ฉด(๋กœ๊ทธ๋Š” ์ตœ์‹ ์ˆœ ์ •๋ ฌ) ๊ฑด๋„ˆ๋›ด๋‹ค. if (result.containsKey(studentId)) continue; final String rawName = log['name']?.toString() ?? ''; final match = _logNamePattern.firstMatch(rawName); result[studentId] = { 'name': match != null ? match.group(1)! : rawName, 'pocketNumber': match != null ? match.group(2)! : '์ฃผ๋จธ๋‹ˆ ๋ฏธ์ง€์ •', 'time': time, }; } return result; } /// ์ง€๊ธˆ๊นŒ์ง€(์˜ค๋Š˜ ์ด์ „ ํฌํ•จ) ๋‹จ ํ•œ ๋ฒˆ์ด๋ผ๋„ ํƒœ๊น…ํ•œ ์  ์žˆ๋Š” ํ•™์ƒ ํ•™๋ฒˆ ์ง‘ํ•ฉ. /// (์˜ค๋Š˜ ๋ฏธ์ œ์ถœ์ธ ํ•™์ƒ ์ค‘์—์„œ๋„ "ํ•œ ๋ฒˆ๋„ ํƒœ๊น… ์•ˆ ํ•ด๋ณธ ์• "๋ฅผ ๊ตฌ๋ถ„ํ•˜๊ธฐ ์œ„ํ•จ) Set get _everCheckedInStudentIds { final Set ids = {}; for (final log in _logs) { final String studentId = log['student_id']?.toString() ?? ''; if (studentId.isNotEmpty) ids.add(studentId); } return ids; } /// โฐ ํƒœ๊น… ์‹œ๊ฐ„๊ณผ ์ง€์ •๋œ ์ž์Šต์‹ค ์ถœ์„์‹œ๊ฐ„์„ ๋น„๊ตํ•ด '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; final everCheckedIn = _everCheckedInStudentIds; 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', 'hasEverCheckedIn': everCheckedIn.contains(id), 'hasActiveViolation': violation != null, 'violationPocket': violation?['pocket_number'], 'violationTime': violation?['time'], }; }).toList(); } List> get filteredStudents { final all = combinedStudentStatus; if (_filterType == "CHECKED_IN") { return all.where((s) => s['isAttendanceComplete'] == true).toList(); } else if (_filterType == "ABSENT") { return all.where((s) => s['isAttendanceComplete'] == false).toList(); } return all; } }