자습실 출석시간 지정 기능 추가 + 앱 버전 1.1.1

- 대시보드에서 자습실 출석시간(HH:MM) 지정 가능, 하교 시 자동 초기화
- 지정 시간 이전 태깅은 "출석 미완료", 이후는 시간 강조 표시로 구분
This commit is contained in:
2026-08-04 14:01:50 +09:00
parent 4ff063c76d
commit 4137a6815c
2 changed files with 141 additions and 38 deletions
+134 -31
View File
@@ -25,6 +25,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
List<dynamic> _logs = []; // 출석 로그 (/api/logs) List<dynamic> _logs = []; // 출석 로그 (/api/logs)
Map<String, dynamic> _activeViolationsByStudentId = {}; // 무단반출 중인 학생 (/api/violations/active) Map<String, dynamic> _activeViolationsByStudentId = {}; // 무단반출 중인 학생 (/api/violations/active)
String? _dismissedAt; // 오늘 가장 최근 하교 처리 시각 (/api/dismissal/latest). 이 시각 이후 기록만 "오늘 출석"으로 표시. String? _dismissedAt; // 오늘 가장 최근 하교 처리 시각 (/api/dismissal/latest). 이 시각 이후 기록만 "오늘 출석"으로 표시.
String? _attendanceTime; // 선생님이 지정한 자습실 출석시간 "HH:MM" (/api/settings/attendance-time). null이면 미설정.
Timer? _timer; Timer? _timer;
bool _isLoading = true; bool _isLoading = true;
bool _isRefreshing = false; // 새로고침 버튼 클릭 시 잠깐 도는 표시용 bool _isRefreshing = false; // 새로고침 버튼 클릭 시 잠깐 도는 표시용
@@ -56,12 +57,14 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
http.get(Uri.parse('$baseUrl/api/logs')), http.get(Uri.parse('$baseUrl/api/logs')),
http.get(Uri.parse('$baseUrl/api/violations/active')), http.get(Uri.parse('$baseUrl/api/violations/active')),
http.get(Uri.parse('$baseUrl/api/dismissal/latest')), http.get(Uri.parse('$baseUrl/api/dismissal/latest')),
http.get(Uri.parse('$baseUrl/api/settings/attendance-time')),
]); ]);
final usersRes = results[0]; final usersRes = results[0];
final logsRes = results[1]; final logsRes = results[1];
final violationsRes = results[2]; final violationsRes = results[2];
final dismissalRes = results[3]; final dismissalRes = results[3];
final attendanceTimeRes = results[4];
if (usersRes.statusCode == 200 && logsRes.statusCode == 200) { if (usersRes.statusCode == 200 && logsRes.statusCode == 200) {
final usersData = jsonDecode(utf8.decode(usersRes.bodyBytes)); final usersData = jsonDecode(utf8.decode(usersRes.bodyBytes));
@@ -81,11 +84,19 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
dismissedAt = dismissalData['dismissedAt']; dismissedAt = dismissalData['dismissedAt'];
} }
String? attendanceTime;
if (attendanceTimeRes.statusCode == 200) {
final attendanceTimeData =
jsonDecode(utf8.decode(attendanceTimeRes.bodyBytes));
attendanceTime = attendanceTimeData['attendanceTime'];
}
setState(() { setState(() {
_roster = usersData['users'] ?? []; _roster = usersData['users'] ?? [];
_logs = logsData['logs'] ?? []; _logs = logsData['logs'] ?? [];
_activeViolationsByStudentId = activeViolations; _activeViolationsByStudentId = activeViolations;
_dismissedAt = dismissedAt; _dismissedAt = dismissedAt;
_attendanceTime = attendanceTime;
_isLoading = false; _isLoading = false;
}); });
} else { } else {
@@ -158,6 +169,48 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
} }
} }
/// ⏰ 자습실 출석시간(기준 시각)을 지정한다. 이 시각 이후 태깅한 학생만 "출석 완료"로 강조 표시된다.
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);
}
void _showAllowDialog(String studentId, String studentName) { void _showAllowDialog(String studentId, String studentName) {
final controller = TextEditingController(text: "5"); final controller = TextEditingController(text: "5");
showDialog( showDialog(
@@ -290,18 +343,34 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
return result; return result;
} }
/// ⏰ 태깅 시간과 지정된 자습실 출석시간을 비교해 '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 { List<Map<String, dynamic>> get _combinedStudentStatus {
final checkIns = _todaysCheckInsByStudentId; final checkIns = _todaysCheckInsByStudentId;
return _roster.map((u) { return _roster.map((u) {
final String id = u['id']?.toString() ?? ''; final String id = u['id']?.toString() ?? '';
final checkIn = checkIns[id]; final checkIn = checkIns[id];
final violation = _activeViolationsByStudentId[id]; final violation = _activeViolationsByStudentId[id];
final String attendanceStatus = _computeAttendanceStatus(checkIn?['time']);
return { return {
'studentId': id, 'studentId': id,
'studentName': u['name']?.toString() ?? '', 'studentName': u['name']?.toString() ?? '',
'isCheckedIn': checkIn != null, 'isCheckedIn': checkIn != null,
'pocketNumber': checkIn?['pocketNumber'], 'pocketNumber': checkIn?['pocketNumber'],
'checkInTime': checkIn?['time'], 'checkInTime': checkIn?['time'],
'attendanceStatus': attendanceStatus,
'isAttendanceComplete': attendanceStatus == 'COMPLETE',
'hasActiveViolation': violation != null, 'hasActiveViolation': violation != null,
'violationPocket': violation?['pocket_number'], 'violationPocket': violation?['pocket_number'],
'violationTime': violation?['time'], 'violationTime': violation?['time'],
@@ -312,9 +381,9 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
List<Map<String, dynamic>> get _filteredStudents { List<Map<String, dynamic>> get _filteredStudents {
final all = _combinedStudentStatus; final all = _combinedStudentStatus;
if (_filterType == "CHECKED_IN") { if (_filterType == "CHECKED_IN") {
return all.where((s) => s['isCheckedIn'] == true).toList(); return all.where((s) => s['isAttendanceComplete'] == true).toList();
} else if (_filterType == "ABSENT") { } else if (_filterType == "ABSENT") {
return all.where((s) => s['isCheckedIn'] == false).toList(); return all.where((s) => s['isAttendanceComplete'] == false).toList();
} }
return all; return all;
} }
@@ -323,7 +392,8 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final all = _combinedStudentStatus; final all = _combinedStudentStatus;
final int totalCount = all.length; final int totalCount = all.length;
final int checkedInCount = all.where((s) => s['isCheckedIn'] == true).length; final int checkedInCount =
all.where((s) => s['isAttendanceComplete'] == true).length;
final int absentCount = totalCount - checkedInCount; final int absentCount = totalCount - checkedInCount;
return Scaffold( return Scaffold(
@@ -351,6 +421,14 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
: const Icon(Icons.refresh_rounded), : const Icon(Icons.refresh_rounded),
tooltip: '새로고침', 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( TextButton.icon(
onPressed: _showPermissionWindowDialog, onPressed: _showPermissionWindowDialog,
icon: const Icon(Icons.timer_outlined, color: Colors.white), icon: const Icon(Icons.timer_outlined, color: Colors.white),
@@ -406,6 +484,14 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
final student = _filteredStudents[index]; final student = _filteredStudents[index];
final bool isCheckedIn = student['isCheckedIn']; final bool isCheckedIn = student['isCheckedIn'];
final bool hasViolation = student['hasActiveViolation'] == true; 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( return Container(
margin: const EdgeInsets.symmetric( margin: const EdgeInsets.symmetric(
horizontal: 24, horizontal: 24,
@@ -434,21 +520,18 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
Container( Container(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: (hasViolation color: statusColor.withValues(alpha: 0.1),
? Colors.red
: (isCheckedIn ? Colors.blue : Colors.red))
.withValues(alpha: 0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(
hasViolation hasViolation
? Icons.warning_amber_rounded ? Icons.warning_amber_rounded
: (isCheckedIn : (isPending
? Icons.hourglass_bottom_rounded
: (isComplete
? Icons.check_circle_rounded ? Icons.check_circle_rounded
: Icons.error_rounded), : Icons.error_rounded)),
color: hasViolation color: statusColor,
? Colors.red
: (isCheckedIn ? Colors.blue : Colors.red),
size: 24, size: 24,
), ),
), ),
@@ -468,17 +551,21 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
Text( Text(
hasViolation hasViolation
? '🚨 무단반출 감지! (${student['violationTime']})' ? '🚨 무단반출 감지! (${student['violationTime']})'
: (isCheckedIn : (isPending
? '학번: ${student['studentId']} | ⏳ 출석 미완료 (제출: ${student['checkInTime']})'
: (isComplete
? '학번: ${student['studentId']} | 제출시간: ${student['checkInTime']}' ? '학번: ${student['studentId']} | 제출시간: ${student['checkInTime']}'
: '학번: ${student['studentId']} | 미제출'), : '학번: ${student['studentId']} | 미제출')),
style: TextStyle( style: TextStyle(
color: hasViolation color: hasViolation
? Colors.red[700] ? Colors.red[700]
: (isCheckedIn : (isPending
? Colors.orange[800]
: (isComplete
? Colors.grey[600] ? Colors.grey[600]
: Colors.red[400]), : Colors.red[400])),
fontSize: 12, fontSize: emphasizeTime ? 14 : 12,
fontWeight: hasViolation fontWeight: (hasViolation || emphasizeTime)
? FontWeight.bold ? FontWeight.bold
: FontWeight.normal, : FontWeight.normal,
), ),
@@ -621,23 +708,34 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
final bool isCheckedIn = student['isCheckedIn']; final bool isCheckedIn = student['isCheckedIn'];
final bool hasViolation = final bool hasViolation =
student['hasActiveViolation'] == true; 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( return DataRow(
color: hasViolation color: hasViolation
? WidgetStateProperty.all(Colors.red[50]) ? WidgetStateProperty.all(Colors.red[50])
: null, : (isPending
? WidgetStateProperty.all(Colors.orange[50])
: null),
cells: [ cells: [
DataCell( DataCell(
Icon( Icon(
hasViolation hasViolation
? Icons.warning_amber_rounded ? Icons.warning_amber_rounded
: (isCheckedIn : (isPending
? Icons.hourglass_bottom_rounded
: (isComplete
? Icons.check_circle_rounded ? Icons.check_circle_rounded
: Icons.error_rounded), : Icons.error_rounded)),
color: hasViolation color: statusColor,
? Colors.red
: (isCheckedIn
? Colors.blue
: Colors.red),
size: 20, size: 20,
), ),
), ),
@@ -654,16 +752,21 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
Text( Text(
hasViolation hasViolation
? '🚨 무단반출 (${student['violationTime']})' ? '🚨 무단반출 (${student['violationTime']})'
: (isCheckedIn : (isPending
? '⏳ 출석 미완료 (제출: ${student['checkInTime']})'
: (isComplete
? '${student['checkInTime']}' ? '${student['checkInTime']}'
: '미제출'), : '미제출')),
style: TextStyle( style: TextStyle(
color: hasViolation color: hasViolation
? Colors.red[700] ? Colors.red[700]
: (isCheckedIn : (isPending
? Colors.orange[800]
: (isComplete
? Colors.grey[700] ? Colors.grey[700]
: Colors.red[400]), : Colors.red[400])),
fontWeight: hasViolation fontSize: emphasizeTime ? 15 : 14,
fontWeight: (hasViolation || emphasizeTime)
? FontWeight.bold ? FontWeight.bold
: FontWeight.normal, : FontWeight.normal,
), ),
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1 version: 1.1.1+2
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2