화면 타이틀, 스낵바/다이얼로그 메시지, 백그라운드 알림 문구 등 사용자에게 노출되는 텍스트에서 이모티콘을 전부 제거 (코드 주석은 대상 아님). 아이콘 위젯은 그대로 유지. main_dashboard.dart의 "계정 강제 삭제" 타일 아이콘 색상도 빨간색 대신 다른 타일과 동일한 검정(AppPalette.ink)으로 통일. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
350 lines
13 KiB
Dart
350 lines
13 KiB
Dart
// 📋 실시간 출석 현황 화면의 기능(상태/서버 통신/파생 로직) 담당 컨트롤러.
|
|
// UI(lib/ui/teacher_attendance_page.dart)는 이 컨트롤러를 구독해서 화면만 그린다.
|
|
// 이 파일에는 위젯/BuildContext/다이얼로그 코드가 없어야 한다 — 전부 UI 파일 몫.
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import '../config.dart';
|
|
|
|
// 로그의 'name' 컬럼은 백엔드에서 "학생이름 (주머니정보)" 형태로 합쳐져 저장되어 있어서
|
|
// 이름과 주머니 번호를 분리해서 보여주려면 클라이언트에서 파싱해야 한다.
|
|
final RegExp _logNamePattern = RegExp(r'^(.*?)\s*\(([^)]*)\)$');
|
|
|
|
class TeacherAttendanceController extends ChangeNotifier {
|
|
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이면 미설정.
|
|
String?
|
|
_globalPermissionUntil; // 하교(12시간)/반출허용시간설정으로 전체 반출이 허용된 경우 그 만료 시각 (/api/permissions/status). null이면 비활성.
|
|
Timer? _timer;
|
|
bool _isLoading = true;
|
|
bool _isRefreshing = false; // 새로고침 버튼 클릭 시 잠깐 도는 표시용
|
|
String _filterType = "ALL"; // "ALL", "CHECKED_IN", "ABSENT"
|
|
String _gradeFilter = "ALL"; // "ALL", "1", "2", "3"
|
|
bool _disposed = false;
|
|
|
|
// ----------------------------------------------------------------
|
|
// UI가 읽는 공개 상태
|
|
// ----------------------------------------------------------------
|
|
bool get isLoading => _isLoading;
|
|
bool get isRefreshing => _isRefreshing;
|
|
String get filterType => _filterType;
|
|
String get gradeFilter => _gradeFilter;
|
|
String? get attendanceTime => _attendanceTime;
|
|
String? get globalPermissionUntil => _globalPermissionUntil;
|
|
|
|
void _safeNotify() {
|
|
if (!_disposed) notifyListeners();
|
|
}
|
|
|
|
void init() {
|
|
fetchAll();
|
|
_timer = Timer.periodic(const Duration(seconds: 3), (timer) => fetchAll());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_disposed = true;
|
|
_timer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> manualRefresh() async {
|
|
_isRefreshing = true;
|
|
_safeNotify();
|
|
await fetchAll();
|
|
_isRefreshing = false;
|
|
_safeNotify();
|
|
}
|
|
|
|
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')),
|
|
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<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'];
|
|
}
|
|
|
|
String? globalPermissionUntil;
|
|
if (permissionStatusRes.statusCode == 200) {
|
|
final permissionStatusData = jsonDecode(
|
|
utf8.decode(permissionStatusRes.bodyBytes),
|
|
);
|
|
if (permissionStatusData['active'] == true) {
|
|
globalPermissionUntil = permissionStatusData['permittedUntil'];
|
|
}
|
|
}
|
|
|
|
_roster = usersData['users'] ?? [];
|
|
_logs = logsData['logs'] ?? [];
|
|
_activeViolationsByStudentId = activeViolations;
|
|
_dismissedAt = dismissedAt;
|
|
_attendanceTime = attendanceTime;
|
|
_globalPermissionUntil = globalPermissionUntil;
|
|
_isLoading = false;
|
|
_safeNotify();
|
|
} else {
|
|
_isLoading = false;
|
|
_safeNotify();
|
|
}
|
|
} catch (e) {
|
|
_isLoading = false;
|
|
_safeNotify();
|
|
}
|
|
}
|
|
|
|
/// 🏫 하교 처리: 전체 반출 허용 + 오늘 출석 표시 기준선을 지금 시각으로 옮긴다.
|
|
Future<String> dismissAll() async {
|
|
try {
|
|
final response = await http.post(Uri.parse('$baseUrl/api/dismiss'));
|
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
|
await fetchAll();
|
|
return result['message'] ?? '하교 처리되었습니다.';
|
|
} catch (e) {
|
|
return '하교 처리 실패: $e';
|
|
}
|
|
}
|
|
|
|
/// 🚨 선생님이 특정 학생에게 지금부터 N분간 반출을 허용한다.
|
|
Future<String> 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));
|
|
await fetchAll();
|
|
return result['message'] ?? '처리되었습니다.';
|
|
} catch (e) {
|
|
return '반출 허용 실패: $e';
|
|
}
|
|
}
|
|
|
|
/// ⏰ 지금부터 N분간 전체 학생의 반출을 자동으로 허용한다 (쉬는시간 등).
|
|
Future<String> 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));
|
|
await fetchAll();
|
|
return result['message'] ?? '처리되었습니다.';
|
|
} catch (e) {
|
|
return '설정 실패: $e';
|
|
}
|
|
}
|
|
|
|
/// ⏰ 자습실 출석시간(기준 시각)을 지정한다. 이 시각 이후 태깅한 학생만 "출석 완료"로 강조 표시된다.
|
|
Future<String> 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));
|
|
await fetchAll();
|
|
return result['message'] ?? '처리되었습니다.';
|
|
} catch (e) {
|
|
return '출석시간 설정 실패: $e';
|
|
}
|
|
}
|
|
|
|
/// 🧪 테스트용: 하교(12시간)/반출 허용 시간 설정 등으로 켜져 있는 허용 시간대를 즉시 해제한다.
|
|
Future<String> resetTestPermissions() async {
|
|
try {
|
|
final response = await http.post(
|
|
Uri.parse('$baseUrl/api/debug/reset-permissions'),
|
|
);
|
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
|
await fetchAll();
|
|
return result['message'] ?? '처리되었습니다.';
|
|
} catch (e) {
|
|
return '초기화 실패: $e';
|
|
}
|
|
}
|
|
|
|
void setFilter(String type) {
|
|
_filterType = type;
|
|
_safeNotify();
|
|
}
|
|
|
|
/// 🏫 학년 필터. "ALL"이면 전체, "1"/"2"/"3"이면 그 학년만 보이게 한다.
|
|
void setGradeFilter(String grade) {
|
|
_gradeFilter = grade;
|
|
_safeNotify();
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// 파생 로직 (출석 상태 계산)
|
|
// ----------------------------------------------------------------
|
|
|
|
/// "오늘 출석"의 기준선. 하교 처리를 했다면 그 시각 이후, 안 했다면 오늘 자정부터.
|
|
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() ?? '',
|
|
'grade': u['grade'] as int?,
|
|
'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 {
|
|
var result = combinedStudentStatus;
|
|
if (_filterType == "CHECKED_IN") {
|
|
result = result.where((s) => s['isAttendanceComplete'] == true).toList();
|
|
} else if (_filterType == "ABSENT") {
|
|
result = result.where((s) => s['isAttendanceComplete'] == false).toList();
|
|
}
|
|
if (_gradeFilter != "ALL") {
|
|
final int? grade = int.tryParse(_gradeFilter);
|
|
result = result.where((s) => s['grade'] == grade).toList();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// 🏫 [학년별 카테고리] filteredStudents를 학년(1~3학년) 순서로 묶는다.
|
|
/// 학년이 아직 지정 안 된 학생은 맨 뒤 "미배정" 그룹으로 모인다.
|
|
List<MapEntry<String, List<Map<String, dynamic>>>> get studentsByGrade {
|
|
final Map<int, List<Map<String, dynamic>>> byGrade = {};
|
|
final List<Map<String, dynamic>> ungraded = [];
|
|
|
|
for (final student in filteredStudents) {
|
|
final int? grade = student['grade'] as int?;
|
|
if (grade == null) {
|
|
ungraded.add(student);
|
|
} else {
|
|
byGrade.putIfAbsent(grade, () => []).add(student);
|
|
}
|
|
}
|
|
|
|
final sortedGrades = byGrade.keys.toList()..sort();
|
|
final groups = <MapEntry<String, List<Map<String, dynamic>>>>[
|
|
for (final grade in sortedGrades) MapEntry('$grade학년', byGrade[grade]!),
|
|
];
|
|
if (ungraded.isNotEmpty) {
|
|
groups.add(MapEntry('미배정', ungraded));
|
|
}
|
|
return groups;
|
|
}
|
|
}
|