// ๐Ÿ“‹ ์‹ค์‹œ๊ฐ„ ์ถœ์„ ํ˜„ํ™ฉ ํ™”๋ฉด. ์ „์ฒด ํ•™์ƒ ๋ช…๋‹จ(/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) Timer? _timer; bool _isLoading = true; 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 _fetchAll() async { try { final results = await Future.wait([ http.get(Uri.parse('$baseUrl/api/users')), http.get(Uri.parse('$baseUrl/api/logs')), ]); final usersRes = results[0]; final logsRes = results[1]; if (usersRes.statusCode == 200 && logsRes.statusCode == 200) { final usersData = jsonDecode(utf8.decode(usersRes.bodyBytes)); final logsData = jsonDecode(utf8.decode(logsRes.bodyBytes)); setState(() { _roster = usersData['users'] ?? []; _logs = logsData['logs'] ?? []; _isLoading = false; }); } else { setState(() => _isLoading = false); } } catch (e) { setState(() => _isLoading = false); } } /// ์˜ค๋Š˜ ๋‚ ์งœ ๊ธฐ์ค€์œผ๋กœ ํ•™๋ฒˆ๋ณ„ "๊ฐ€์žฅ ์ตœ๊ทผ ์ถœ์„ ๊ธฐ๋ก"๋งŒ ๋‚จ๊ธด ๋งต์„ ๋งŒ๋“ ๋‹ค. Map> get _todaysCheckInsByStudentId { final String todayPrefix = DateTime.now().toIso8601String().substring( 0, 10, ); // "YYYY-MM-DD" final Map> result = {}; for (final log in _logs) { final String time = log['time']?.toString() ?? ''; if (!time.startsWith(todayPrefix)) 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; } List> get _combinedStudentStatus { final checkIns = _todaysCheckInsByStudentId; return _roster.map((u) { final String id = u['id']?.toString() ?? ''; final checkIn = checkIns[id]; return { 'studentId': id, 'studentName': u['name']?.toString() ?? '', 'isCheckedIn': checkIn != null, 'pocketNumber': checkIn?['pocketNumber'], 'checkInTime': checkIn?['time'], }; }).toList(); } List> get _filteredStudents { final all = _combinedStudentStatus; if (_filterType == "CHECKED_IN") { return all.where((s) => s['isCheckedIn'] == true).toList(); } else if (_filterType == "ABSENT") { return all.where((s) => s['isCheckedIn'] == false).toList(); } return all; } @override Widget build(BuildContext context) { final all = _combinedStudentStatus; final int totalCount = all.length; final int checkedInCount = all.where((s) => s['isCheckedIn'] == 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, ), body: _isLoading ? const Center(child: CircularProgressIndicator()) : Column( children: [ _buildSummaryCards(totalCount, checkedInCount, absentCount), _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']; return Container( margin: const EdgeInsets.symmetric( horizontal: 24, vertical: 8, ), padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.03), blurRadius: 12, offset: const Offset(0, 4), ), ], ), child: Row( children: [ Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: (isCheckedIn ? Colors.blue : Colors.red) .withValues(alpha: 0.1), shape: BoxShape.circle, ), child: Icon( isCheckedIn ? Icons.check_circle_rounded : Icons.error_rounded, color: isCheckedIn ? Colors.blue : Colors.red, 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( isCheckedIn ? 'ํ•™๋ฒˆ: ${student['studentId']} | ์ œ์ถœ์‹œ๊ฐ„: ${student['checkInTime']}' : 'ํ•™๋ฒˆ: ${student['studentId']} | ๋ฏธ์ œ์ถœ', style: TextStyle( color: isCheckedIn ? Colors.grey[600] : Colors.red[400], fontSize: 12, ), ), ], ), ), 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, ), ), ), ], ), ); }, ), ), ], ), ); } /// ๐Ÿ“Š ์ƒ๋‹จ ์š”์•ฝ ์นด๋“œ ๋ทฐ (์ „์ฒด / ์ถœ์„ ์™„๋ฃŒ / ๋ฏธ์ถœ์„) 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, ), ), ], ); } /// ๐Ÿ”˜ ํ•„ํ„ฐ ์นฉ๋ฒ„ํŠผ (์ „์ฒด / ์ถœ์„์ž / ๋ฏธ์ถœ์„์ž) 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"), ), ], ), ); } }