// ๐Ÿ“‹ ์‹ค์‹œ๊ฐ„ ์ถœ์„ ํ˜„ํ™ฉ ํ™”๋ฉด. ์ „์ฒด ํ•™์ƒ ๋ช…๋‹จ(/api/users)๊ณผ ์ถœ์„ ๋กœ๊ทธ(/api/logs)๋ฅผ // ํ•ฉ์ณ์„œ ์ถœ์„/๋ฏธ์ถœ์„ ์š”์•ฝ ์นด๋“œ ๋ฐ ํ•„ํ„ฐ๋ง๋œ ๋ชฉ๋ก์„ 3์ดˆ๋งˆ๋‹ค ๊ฐฑ์‹ ํ•ด ๋ณด์—ฌ์ค€๋‹ค. import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import '../config.dart'; // ----------------------------------------------------------------------------- // ๐Ÿ“… [์„œ๋ธŒ ํ™”๋ฉด 1] ์‹ค์‹œ๊ฐ„ ์ถœ์„ ํ™•์ธ ๋ž€ (StudentDashboard ์นด๋“œ ์Šคํƒ€์ผ ๋ฆฌ์ŠคํŠธํ™”) // ----------------------------------------------------------------------------- class TeacherAttendancePage extends StatefulWidget { const TeacherAttendancePage({super.key}); @override State createState() => _TeacherAttendancePageState(); } // ๋กœ๊ทธ์˜ 'name' ์ปฌ๋Ÿผ์€ ๋ฐฑ์—”๋“œ์—์„œ "ํ•™์ƒ์ด๋ฆ„ (์ฃผ๋จธ๋‹ˆ์ •๋ณด)" ํ˜•ํƒœ๋กœ ํ•ฉ์ณ์ ธ ์ €์žฅ๋˜์–ด ์žˆ์–ด์„œ // ์ด๋ฆ„๊ณผ ์ฃผ๋จธ๋‹ˆ ๋ฒˆํ˜ธ๋ฅผ ๋ถ„๋ฆฌํ•ด์„œ ๋ณด์—ฌ์ฃผ๋ ค๋ฉด ํด๋ผ์ด์–ธํŠธ์—์„œ ํŒŒ์‹ฑํ•ด์•ผ ํ•œ๋‹ค. final RegExp _logNamePattern = RegExp(r'^(.*?)\s*\(([^)]*)\)$'); class _TeacherAttendancePageState extends State { 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" @override void initState() { super.initState(); _fetchAll(); _timer = Timer.periodic(const Duration(seconds: 3), (timer) => _fetchAll()); } @override void dispose() { _timer?.cancel(); super.dispose(); } Future _manualRefresh() async { setState(() => _isRefreshing = true); await _fetchAll(); if (mounted) setState(() => _isRefreshing = false); } 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']; } } setState(() { _roster = usersData['users'] ?? []; _logs = logsData['logs'] ?? []; _activeViolationsByStudentId = activeViolations; _dismissedAt = dismissedAt; _attendanceTime = attendanceTime; _globalPermissionUntil = globalPermissionUntil; _isLoading = false; }); } else { setState(() => _isLoading = false); } } catch (e) { setState(() => _isLoading = false); } } /// ๐Ÿซ ํ•˜๊ต ์ฒ˜๋ฆฌ: ์ „์ฒด ๋ฐ˜์ถœ ํ—ˆ์šฉ + ์˜ค๋Š˜ ์ถœ์„ ํ‘œ์‹œ ๊ธฐ์ค€์„ ์„ ์ง€๊ธˆ ์‹œ๊ฐ์œผ๋กœ ์˜ฎ๊ธด๋‹ค. Future _dismissAll() async { try { final response = await http.post(Uri.parse('$baseUrl/api/dismiss')); 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')), ); } } /// ๐Ÿšจ ์„ ์ƒ๋‹˜์ด ํŠน์ • ํ•™์ƒ์—๊ฒŒ ์ง€๊ธˆ๋ถ€ํ„ฐ 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)); 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')), ); } } /// โฐ ์ง€๊ธˆ๋ถ€ํ„ฐ 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)); 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 _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); } /// ๐Ÿงช ํ…Œ์ŠคํŠธ์šฉ: ํ•˜๊ต(12์‹œ๊ฐ„)/๋ฐ˜์ถœ ํ—ˆ์šฉ ์‹œ๊ฐ„ ์„ค์ • ๋“ฑ์œผ๋กœ ์ผœ์ ธ ์žˆ๋Š” ํ—ˆ์šฉ ์‹œ๊ฐ„๋Œ€๋ฅผ ์ฆ‰์‹œ ํ•ด์ œํ•œ๋‹ค. Future _resetTestPermissions() async { try { final response = await http.post(Uri.parse('$baseUrl/api/debug/reset-permissions')); 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')), ); } } void _showTestResetConfirmDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('๐Ÿงช ํ…Œ์ŠคํŠธ์šฉ ํ—ˆ์šฉ์‹œ๊ฐ„ ์ดˆ๊ธฐํ™”'), content: const Text( 'ํ•˜๊ต ์ฒ˜๋ฆฌ๋‚˜ ๋ฐ˜์ถœ ํ—ˆ์šฉ ์‹œ๊ฐ„ ์„ค์ •์œผ๋กœ ์ผœ์ ธ ์žˆ๋Š” ๋ชจ๋“  ํ—ˆ์šฉ ์‹œ๊ฐ„๋Œ€๋ฅผ ์ง€๊ธˆ ์ฆ‰์‹œ ํ•ด์ œํ•ฉ๋‹ˆ๋‹ค.\n' '(๋ฌด๋‹จ๋ฐ˜์ถœ ๊ฐ์ง€ ํ…Œ์ŠคํŠธํ•  ๋•Œ๋งŒ ์‚ฌ์šฉํ•˜์„ธ์š”)', ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('์ทจ์†Œ'), ), ElevatedButton( onPressed: () { Navigator.pop(context); _resetTestPermissions(); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.grey[700], foregroundColor: Colors.white, ), child: const Text('์ดˆ๊ธฐํ™”'), ), ], ), ); } void _showAllowDialog(String studentId, String studentName) { final controller = TextEditingController(text: "5"); showDialog( context: context, builder: (context) => AlertDialog( title: Text('$studentName ํ•™์ƒ ๋ฐ˜์ถœ ํ—ˆ์šฉ'), content: TextField( controller: controller, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: 'ํ—ˆ์šฉ ์‹œ๊ฐ„ (๋ถ„)', border: OutlineInputBorder(), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('์ทจ์†Œ'), ), ElevatedButton( onPressed: () { final minutes = int.tryParse(controller.text.trim()) ?? 5; Navigator.pop(context); _allowRemoval(studentId, minutes); }, child: const Text('ํ—ˆ์šฉํ•˜๊ธฐ'), ), ], ), ); } void _showPermissionWindowDialog() { final controller = TextEditingController(text: "10"); showDialog( context: context, builder: (context) => AlertDialog( title: const Text('โฐ ์ „์ฒด ํ•™์ƒ ๋ฐ˜์ถœ ํ—ˆ์šฉ ์‹œ๊ฐ„ ์„ค์ •'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '์‰ฌ๋Š”์‹œ๊ฐ„์ฒ˜๋Ÿผ ์ง€๊ธˆ๋ถ€ํ„ฐ ์ผ์ • ์‹œ๊ฐ„ ๋™์•ˆ ๋ชจ๋“  ํ•™์ƒ์˜ ๋ฐ˜์ถœ์„ ์ž๋™์œผ๋กœ ํ—ˆ์šฉํ•ฉ๋‹ˆ๋‹ค.', style: TextStyle(fontSize: 13, color: Colors.grey), ), const SizedBox(height: 16), TextField( controller: controller, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: '์ง€๊ธˆ๋ถ€ํ„ฐ ํ—ˆ์šฉ ์‹œ๊ฐ„ (๋ถ„)', border: OutlineInputBorder(), ), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('์ทจ์†Œ'), ), ElevatedButton( onPressed: () { final minutes = int.tryParse(controller.text.trim()) ?? 10; Navigator.pop(context); _setPermissionWindow(minutes); }, child: const Text('์„ค์ •ํ•˜๊ธฐ'), ), ], ), ); } /// ๐Ÿซ ํ•˜๊ต ์ฒ˜๋ฆฌ: ์‹œ๊ฐ„ ์ž…๋ ฅ ์—†์ด ๋ฐ”๋กœ ์ „์ฒด ํ•™์ƒ์˜ ๋ฐ˜์ถœ์„ (์‚ฌ์‹ค์ƒ ๋ฌด๊ธฐํ•œ) ํ—ˆ์šฉํ•œ๋‹ค. void _showDismissalConfirmDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('๐Ÿซ ํ•˜๊ต ์ฒ˜๋ฆฌ'), content: const Text( '์ง€๊ธˆ๋ถ€ํ„ฐ ๋ชจ๋“  ํ•™์ƒ์˜ ๋ฐ˜์ถœ์ด ์ž๋™์œผ๋กœ ํ—ˆ์šฉ๋˜๋ฉฐ, ๋” ์ด์ƒ ๋ฌด๋‹จ๋ฐ˜์ถœ ๊ฒฝ๊ณ ๊ฐ€ ๋œจ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.\nํ•˜๊ต ์ฒ˜๋ฆฌํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?', ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('์ทจ์†Œ'), ), ElevatedButton( onPressed: () { Navigator.pop(context); _dismissAll(); }, child: const Text('ํ•˜๊ต ์ฒ˜๋ฆฌ'), ), ], ), ); } /// "์˜ค๋Š˜ ์ถœ์„"์˜ ๊ธฐ์ค€์„ . ํ•˜๊ต ์ฒ˜๋ฆฌ๋ฅผ ํ–ˆ๋‹ค๋ฉด ๊ทธ ์‹œ๊ฐ ์ดํ›„, ์•ˆ ํ–ˆ๋‹ค๋ฉด ์˜ค๋Š˜ ์ž์ •๋ถ€ํ„ฐ. 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; } @override Widget build(BuildContext context) { final all = _combinedStudentStatus; final int totalCount = all.length; final int checkedInCount = all.where((s) => s['isAttendanceComplete'] == true).length; final int absentCount = totalCount - checkedInCount; return Scaffold( backgroundColor: Colors.grey[100], appBar: AppBar( title: const Text( '๐Ÿ“‹ ์‹ค์‹œ๊ฐ„ ์ถœ์„ ํ˜„ํ™ฉ', style: TextStyle(fontWeight: FontWeight.bold), ), backgroundColor: Colors.blue, foregroundColor: Colors.white, elevation: 0, actions: [ IconButton( onPressed: (_isLoading || _isRefreshing) ? null : _manualRefresh, icon: _isRefreshing ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : 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), label: const Text( '๋ฐ˜์ถœ ํ—ˆ์šฉ ์‹œ๊ฐ„ ์„ค์ •', style: TextStyle(color: Colors.white), ), ), TextButton.icon( onPressed: _showDismissalConfirmDialog, icon: const Icon(Icons.school_rounded, color: Colors.white), label: const Text( 'ํ•˜๊ต', style: TextStyle(color: Colors.white), ), ), IconButton( onPressed: _showTestResetConfirmDialog, icon: const Icon(Icons.bug_report_outlined, color: Colors.white70), tooltip: '๐Ÿงช ํ…Œ์ŠคํŠธ์šฉ: ํ—ˆ์šฉ์‹œ๊ฐ„ ์ดˆ๊ธฐํ™”', ), const SizedBox(width: 8), ], ), body: _isLoading ? const Center(child: CircularProgressIndicator()) : LayoutBuilder( builder: (context, constraints) { final bool isWide = constraints.maxWidth >= 800; return isWide ? _buildDesktopBody(totalCount, checkedInCount, absentCount) : _buildMobileBody(totalCount, checkedInCount, absentCount); }, ), ); } // ----------------------------------------------------------------------- // ๐Ÿ“ฑ ๋ชจ๋ฐ”์ผ ๋ ˆ์ด์•„์›ƒ (๊ธฐ์กด ์นด๋“œ ๋ฆฌ์ŠคํŠธ) // ----------------------------------------------------------------------- Widget _buildMobileBody(int total, int checkedIn, int absent) { return Column( children: [ _buildSummaryCards(total, checkedIn, absent), _buildPermissionBanner(), _buildFilterChips(), Expanded( child: _filteredStudents.isEmpty ? const Center( child: Text( 'ํ•ด๋‹นํ•˜๋Š” ํ•™์ƒ์ด ์—†์Šต๋‹ˆ๋‹ค.', style: TextStyle(color: Colors.grey), ), ) : ListView.builder( padding: const EdgeInsets.symmetric(vertical: 12), itemCount: _filteredStudents.length, itemBuilder: (context, index) { 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, vertical: 8, ), padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: hasViolation ? Colors.red[50] : Colors.white, borderRadius: BorderRadius.circular(20), border: hasViolation ? Border.all(color: Colors.red[300]!, width: 1.5) : null, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.03), blurRadius: 12, offset: const Offset(0, 4), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: statusColor.withValues(alpha: 0.1), shape: BoxShape.circle, ), child: Icon( hasViolation ? Icons.warning_amber_rounded : (isPending ? Icons.hourglass_bottom_rounded : (isComplete ? Icons.check_circle_rounded : Icons.error_rounded)), color: statusColor, size: 24, ), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${student['studentName']} ํ•™์ƒ', style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), const SizedBox(height: 4), Text( hasViolation ? '๐Ÿšจ ๋ฌด๋‹จ๋ฐ˜์ถœ ๊ฐ์ง€! (${student['violationTime']})' : (isPending ? 'ํ•™๋ฒˆ: ${student['studentId']} | โณ ์ถœ์„ ๋ฏธ์™„๋ฃŒ (์ œ์ถœ: ${student['checkInTime']})' : (isComplete ? 'ํ•™๋ฒˆ: ${student['studentId']} | ์ œ์ถœ์‹œ๊ฐ„: ${student['checkInTime']}' : (student['hasEverCheckedIn'] == true ? 'ํ•™๋ฒˆ: ${student['studentId']} | ๋ฏธ์ œ์ถœ' : 'ํ•™๋ฒˆ: ${student['studentId']} | ๋ฏธ๋“ฑ๋ก'))), style: TextStyle( color: hasViolation ? Colors.red[700] : (isPending ? Colors.orange[800] : (isComplete ? Colors.grey[600] : Colors.red[400])), fontSize: emphasizeTime ? 14 : 12, fontWeight: (hasViolation || emphasizeTime) ? FontWeight.bold : FontWeight.normal, ), ), ], ), ), if (isCheckedIn && student['pocketNumber'] != null) Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), decoration: BoxDecoration( color: Colors.blue.shade50, borderRadius: BorderRadius.circular(20), border: Border.all(color: Colors.blue.shade200), ), child: Text( student['pocketNumber'], style: const TextStyle( fontWeight: FontWeight.bold, color: Colors.blueAccent, fontSize: 12, ), ), ), ], ), if (hasViolation) Padding( padding: const EdgeInsets.only(top: 12), child: SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: () => _showAllowDialog( student['studentId'], student['studentName'], ), icon: const Icon(Icons.check, size: 18), label: const Text('๋ฐ˜์ถœ ํ—ˆ์šฉ'), style: ElevatedButton.styleFrom( backgroundColor: Colors.red[600], foregroundColor: Colors.white, ), ), ), ), ], ), ); }, ), ), ], ); } // ----------------------------------------------------------------------- // ๐Ÿ–ฅ๏ธ ๋ฐ์Šคํฌํ†ฑ ๋ ˆ์ด์•„์›ƒ (์„ ์ƒ๋‹˜์ด ๊ต์‹ค ์ปดํ“จํ„ฐ ๋ธŒ๋ผ์šฐ์ €๋กœ ์ ‘์†ํ–ˆ์„ ๋•Œ) // ----------------------------------------------------------------------- Widget _buildDesktopBody(int total, int checkedIn, int absent) { return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 1100), child: Padding( padding: const EdgeInsets.all(24.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: _statTile( "์ „์ฒด ํ•™์ƒ", "$total๋ช…", Icons.groups_rounded, Colors.blueGrey, ), ), const SizedBox(width: 16), Expanded( child: _statTile( "์ถœ์„ ์™„๋ฃŒ", "$checkedIn๋ช…", Icons.check_circle_rounded, Colors.green, ), ), const SizedBox(width: 16), Expanded( child: _statTile( "๋ฏธ์ถœ์„", "$absent๋ช…", Icons.error_rounded, Colors.red, ), ), ], ), const SizedBox(height: 20), _buildPermissionBanner(), _buildFilterChips(), const SizedBox(height: 12), Expanded( child: Container( width: double.infinity, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.04), blurRadius: 16, offset: const Offset(0, 4), ), ], ), child: _filteredStudents.isEmpty ? const Center( child: Text( 'ํ•ด๋‹นํ•˜๋Š” ํ•™์ƒ์ด ์—†์Šต๋‹ˆ๋‹ค.', style: TextStyle(color: Colors.grey), ), ) : SingleChildScrollView( child: DataTable( headingRowColor: WidgetStateProperty.all( Colors.grey[50], ), columns: const [ DataColumn(label: Text('์ƒํƒœ')), DataColumn(label: Text('ํ•™๋ฒˆ')), DataColumn(label: Text('์ด๋ฆ„')), DataColumn(label: Text('์ œ์ถœ ์‹œ๊ฐ„')), DataColumn(label: Text('์ฃผ๋จธ๋‹ˆ ๋ฒˆํ˜ธ')), DataColumn(label: Text('์ž‘์—…')), ], rows: _filteredStudents.map((student) { 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]) : (isPending ? WidgetStateProperty.all(Colors.orange[50]) : null), cells: [ DataCell( Icon( hasViolation ? Icons.warning_amber_rounded : (isPending ? Icons.hourglass_bottom_rounded : (isComplete ? Icons.check_circle_rounded : Icons.error_rounded)), color: statusColor, size: 20, ), ), DataCell(Text('${student['studentId']}')), DataCell( Text( '${student['studentName']}', style: const TextStyle( fontWeight: FontWeight.bold, ), ), ), DataCell( Text( hasViolation ? '๐Ÿšจ ๋ฌด๋‹จ๋ฐ˜์ถœ (${student['violationTime']})' : (isPending ? 'โณ ์ถœ์„ ๋ฏธ์™„๋ฃŒ (์ œ์ถœ: ${student['checkInTime']})' : (isComplete ? '${student['checkInTime']}' : (student['hasEverCheckedIn'] == true ? '๋ฏธ์ œ์ถœ' : '๋ฏธ๋“ฑ๋ก'))), style: TextStyle( color: hasViolation ? Colors.red[700] : (isPending ? Colors.orange[800] : (isComplete ? Colors.grey[700] : Colors.red[400])), fontSize: emphasizeTime ? 15 : 14, fontWeight: (hasViolation || emphasizeTime) ? FontWeight.bold : FontWeight.normal, ), ), ), DataCell( isCheckedIn && student['pocketNumber'] != null ? Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4, ), decoration: BoxDecoration( color: Colors.blue.shade50, borderRadius: BorderRadius.circular(20), border: Border.all( color: Colors.blue.shade200, ), ), child: Text( student['pocketNumber'], style: const TextStyle( fontWeight: FontWeight.bold, color: Colors.blueAccent, fontSize: 12, ), ), ) : const Text('-'), ), DataCell( hasViolation ? ElevatedButton.icon( onPressed: () => _showAllowDialog( student['studentId'], student['studentName'], ), icon: const Icon( Icons.check, size: 16, ), label: const Text('๋ฐ˜์ถœ ํ—ˆ์šฉ'), style: ElevatedButton.styleFrom( backgroundColor: Colors.red[600], foregroundColor: Colors.white, padding: const EdgeInsets.symmetric( horizontal: 12, ), ), ) : const Text('-'), ), ], ); }).toList(), ), ), ), ), ], ), ), ), ); } Widget _statTile(String label, String value, IconData icon, Color color) { return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.04), blurRadius: 12, offset: const Offset(0, 4), ), ], ), child: Row( children: [ Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Icon(icon, color: color, size: 26), ), const SizedBox(width: 14), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: TextStyle(color: Colors.grey[600], fontSize: 13), ), const SizedBox(height: 2), Text( value, style: TextStyle( color: color, fontSize: 22, fontWeight: FontWeight.bold, ), ), ], ), ], ), ); } /// ๐Ÿ“Š ์ƒ๋‹จ ์š”์•ฝ ์นด๋“œ ๋ทฐ (์ „์ฒด / ์ถœ์„ ์™„๋ฃŒ / ๋ฏธ์ถœ์„) Widget _buildSummaryCards(int total, int checkedIn, int absent) { return Container( width: double.infinity, padding: const EdgeInsets.all(16), color: const Color(0xFF1E293B), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _summaryCard("์ „์ฒด", "$total๋ช…", Colors.white70), _summaryCard("์ถœ์„ ์™„๋ฃŒ", "$checkedIn๋ช…", Colors.greenAccent), _summaryCard("๋ฏธ์ถœ์„", "$absent๋ช…", Colors.redAccent), ], ), ); } Widget _summaryCard(String title, String count, Color color) { return Column( children: [ Text(title, style: const TextStyle(color: Colors.grey, fontSize: 12)), const SizedBox(height: 4), Text( count, style: TextStyle( color: color, fontSize: 20, fontWeight: FontWeight.bold, ), ), ], ); } /// ๐Ÿ”˜ ํ•„ํ„ฐ ์นฉ๋ฒ„ํŠผ (์ „์ฒด / ์ถœ์„์ž / ๋ฏธ์ถœ์„์ž) /// ๐Ÿ”“ ํ•˜๊ต(12์‹œ๊ฐ„)/๋ฐ˜์ถœ ํ—ˆ์šฉ ์‹œ๊ฐ„ ์„ค์ •์œผ๋กœ ์ง€๊ธˆ ์ „์ฒด ๋ฐ˜์ถœ์ด ํ—ˆ์šฉ ์ค‘์ด๋ฉด ๋ˆˆ์— ๋„๊ฒŒ ๋ฐฐ๋„ˆ๋กœ ์•Œ๋ ค์ค€๋‹ค. /// (ํ—ˆ์šฉ ์ค‘์ผ ๋• ๋ฌด๋‹จ๋ฐ˜์ถœ์„ ๊ฐ์ง€ํ•ด๋„ ๋Œ€์‹œ๋ณด๋“œ์— ๋œจ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์—, ์™œ ์•ˆ ๋œจ๋Š”์ง€ ํ—ท๊ฐˆ๋ฆฌ์ง€ ์•Š๊ฒŒ ํ•˜๊ธฐ ์œ„ํ•จ) Widget _buildPermissionBanner() { if (_globalPermissionUntil == null) return const SizedBox.shrink(); return Container( width: double.infinity, margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( color: Colors.amber[100], borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.amber[400]!), ), child: Row( children: [ Icon(Icons.lock_open_rounded, color: Colors.amber[800], size: 20), const SizedBox(width: 8), Expanded( child: Text( '๐Ÿ”“ ์ง€๊ธˆ ์ „์ฒด ๋ฐ˜์ถœ ํ—ˆ์šฉ ์ค‘์ž…๋‹ˆ๋‹ค ($_globalPermissionUntil ๊นŒ์ง€) โ€” ์ด ์‹œ๊ฐ„ ๋™์•ˆ์€ ๋ฌด๋‹จ๋ฐ˜์ถœ ๊ฒฝ๊ณ ๊ฐ€ ๋œจ์ง€ ์•Š์•„์š”.', style: TextStyle( color: Colors.amber[900], fontWeight: FontWeight.bold, fontSize: 12.5, ), ), ), ], ), ); } Widget _buildFilterChips() { return Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), child: Row( children: [ FilterChip( label: const Text("์ „์ฒด"), selected: _filterType == "ALL", onSelected: (_) => setState(() => _filterType = "ALL"), ), const SizedBox(width: 8), FilterChip( label: const Text("๐ŸŸข ์ถœ์„์ž"), selected: _filterType == "CHECKED_IN", onSelected: (_) => setState(() => _filterType = "CHECKED_IN"), ), const SizedBox(width: 8), FilterChip( label: const Text("๐Ÿ”ด ๋ฏธ์ถœ์„์ž"), selected: _filterType == "ABSENT", onSelected: (_) => setState(() => _filterType = "ABSENT"), ), ], ), ); } }