1018 lines
42 KiB
Dart
1018 lines
42 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)
|
|
Map<String, dynamic> _activeViolationsByStudentId = {}; // 무단반출 중인 학생 (/api/violations/active)
|
|
String? _dismissedAt; // 오늘 가장 최근 하교 처리 시각 (/api/dismissal/latest). 이 시각 이후 기록만 "오늘 출석"으로 표시.
|
|
String? _attendanceTime; // 선생님이 지정한 자습실 출석시간 "HH:MM" (/api/settings/attendance-time). 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<void> _manualRefresh() async {
|
|
setState(() => _isRefreshing = true);
|
|
await _fetchAll();
|
|
if (mounted) setState(() => _isRefreshing = false);
|
|
}
|
|
|
|
Future<void> _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')),
|
|
]);
|
|
|
|
final usersRes = results[0];
|
|
final logsRes = results[1];
|
|
final violationsRes = results[2];
|
|
final dismissalRes = results[3];
|
|
final attendanceTimeRes = results[4];
|
|
|
|
if (usersRes.statusCode == 200 && logsRes.statusCode == 200) {
|
|
final usersData = jsonDecode(utf8.decode(usersRes.bodyBytes));
|
|
final logsData = jsonDecode(utf8.decode(logsRes.bodyBytes));
|
|
|
|
Map<String, dynamic> 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'];
|
|
}
|
|
|
|
setState(() {
|
|
_roster = usersData['users'] ?? [];
|
|
_logs = logsData['logs'] ?? [];
|
|
_activeViolationsByStudentId = activeViolations;
|
|
_dismissedAt = dismissedAt;
|
|
_attendanceTime = attendanceTime;
|
|
_isLoading = false;
|
|
});
|
|
} else {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
} catch (e) {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
/// 🏫 하교 처리: 전체 반출 허용 + 오늘 출석 표시 기준선을 지금 시각으로 옮긴다.
|
|
Future<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<String, Map<String, String>> get _todaysCheckInsByStudentId {
|
|
// "YYYY-MM-DD HH:MM:SS" 형태의 문자열끼리는 그대로 비교해도 시간 순서가 맞는다.
|
|
final String cutoff =
|
|
_dismissedAt ??
|
|
'${DateTime.now().toIso8601String().substring(0, 10)} 00:00:00';
|
|
final Map<String, Map<String, String>> 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<String> get _everCheckedInStudentIds {
|
|
final Set<String> 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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),
|
|
_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),
|
|
_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,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 🔘 필터 칩버튼 (전체 / 출석자 / 미출석자)
|
|
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"),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|