323 lines
13 KiB
Dart
323 lines
13 KiB
Dart
// 📋 실시간 출석 현황 화면. 전체 학생 명단(/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<TeacherAttendancePage> createState() => _TeacherAttendancePageState();
|
|
}
|
|
|
|
// 로그의 'name' 컬럼은 백엔드에서 "학생이름 (주머니정보)" 형태로 합쳐져 저장되어 있어서
|
|
// 이름과 주머니 번호를 분리해서 보여주려면 클라이언트에서 파싱해야 한다.
|
|
final RegExp _logNamePattern = RegExp(r'^(.*?)\s*\(([^)]*)\)$');
|
|
|
|
class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|
List<dynamic> _roster = []; // 전체 학생 명단 (/api/users)
|
|
List<dynamic> _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<void> _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<String, Map<String, String>> get _todaysCheckInsByStudentId {
|
|
final String todayPrefix = DateTime.now().toIso8601String().substring(
|
|
0,
|
|
10,
|
|
); // "YYYY-MM-DD"
|
|
final Map<String, Map<String, String>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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"),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|