기존 프로젝트 최초 업로드
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
// 📋 실시간 출석 현황 화면. 전체 학생 명단(/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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user