Files
school-attendance/lib/ui/teacher_attendance_page.dart
T
sihooandClaude Sonnet 5 d1c59c521b 선생님 대시보드 학년별 카테고리 분류 + 학생 계정 관리 버그 수정
- 실시간 출석 현황을 학년별 섹션으로 묶어서 표시 (백엔드에 grade 필드 추가)
- 학생 계정 추가 폼에 학년 선택 드롭다운 추가
- 학생 계정 생성/삭제가 실제로는 존재하지 않는 엔드포인트를 호출하던
  버그 수정 (/api/users/register-student, /api/users/delete/{id} →
  /api/users/create, /api/users/delete)
- 학생 대시보드(개발자 모드) 카드 그리드가 넓은 화면에서 과도하게
  커지던 레이아웃 버그 수정, 웹은 5열/폰은 2열로 반응형 처리
- 버전 1.2.0+6

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 16:29:40 +09:00

733 lines
25 KiB
Dart

// 📋 실시간 출석 현황 화면 (UI 전용). 위젯 빌드/레이아웃/스타일만 담당하고,
// 서버 통신·상태·파생 로직은 lib/function/teacher_attendance_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/teacher_attendance_controller.dart';
// -----------------------------------------------------------------------------
// 📅 [서브 화면 1] 실시간 출석 확인 란 (StudentDashboard 카드 스타일 리스트화)
// -----------------------------------------------------------------------------
class TeacherAttendancePage extends StatefulWidget {
const TeacherAttendancePage({super.key});
@override
State<TeacherAttendancePage> createState() => _TeacherAttendancePageState();
}
class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
final TeacherAttendanceController _controller = TeacherAttendanceController();
@override
void initState() {
super.initState();
_controller.init();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/// 컨트롤러 액션을 실행하고, 결과 메시지를 스낵바로 보여준다.
Future<void> _runAction(Future<String> Function() action) async {
final message = await action();
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
Future<void> _showAttendanceTimeDialog() async {
final String? current = _controller.attendanceTime;
final TimeOfDay initial = current != null
? TimeOfDay(
hour: int.parse(current.split(':')[0]),
minute: int.parse(current.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')}';
await _runAction(() => _controller.setAttendanceTime(formatted));
}
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);
_runAction(() => _controller.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);
_runAction(() => _controller.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);
_runAction(() => _controller.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);
_runAction(() => _controller.dismissAll());
},
child: const Text('하교 처리'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final all = _controller.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: (_controller.isLoading || _controller.isRefreshing)
? null
: _controller.manualRefresh,
icon: _controller.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(
_controller.attendanceTime != null
? '출석시간 ${_controller.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: _controller.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) {
final groups = _controller.studentsByGrade;
return Column(
children: [
_buildSummaryCards(total, checkedIn, absent),
_buildPermissionBanner(),
_buildFilterChips(),
Expanded(
child: groups.isEmpty
? const Center(
child: Text(
'해당하는 학생이 없습니다.',
style: TextStyle(color: Colors.grey),
),
)
: _buildGradeGroupedList(
groups,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
),
),
],
);
}
/// 🏫 학년별로 섹션 제목을 붙여 그리드를 이어붙인 목록. 모바일/데스크톱 공용.
Widget _buildGradeGroupedList(
List<MapEntry<String, List<Map<String, dynamic>>>> groups, {
required EdgeInsets padding,
}) {
return ListView.builder(
padding: padding,
itemCount: groups.length,
itemBuilder: (context, index) {
final group = groups[index];
return Padding(
padding: EdgeInsets.only(bottom: index == groups.length - 1 ? 0 : 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
group.key,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(width: 6),
Text(
'${group.value.length}명',
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
),
],
),
const SizedBox(height: 10),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 220,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
mainAxisExtent: 148,
),
itemCount: group.value.length,
itemBuilder: (context, i) => _buildStudentTile(group.value[i]),
),
],
),
);
},
);
}
/// 🀄 학생 한 명을 "한눈에 보기" 타일로 그린다. 모바일/데스크톱 그리드에서 공용으로 쓴다.
Widget _buildStudentTile(Map<String, dynamic> 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 Color statusColor = hasViolation
? Colors.red
: (isPending ? Colors.orange : (isComplete ? Colors.blue : Colors.red));
final String statusLine = hasViolation
? '🚨 무단반출 (${student['violationTime']})'
: (isPending
? '⏳ 미완료 (${student['checkInTime']})'
: (isComplete
? '${student['checkInTime']}'
: (student['hasEverCheckedIn'] == true ? '미제출' : '미등록')));
return Container(
padding: const EdgeInsets.all(14),
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(8),
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: 18,
),
),
const Spacer(),
if (isCheckedIn && student['pocketNumber'] != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
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: 11,
),
),
),
],
),
const SizedBox(height: 8),
Text(
'${student['studentName']}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
Text(
'학번 ${student['studentId']}',
style: TextStyle(color: Colors.grey[500], fontSize: 11),
),
const SizedBox(height: 4),
Text(
statusLine,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: hasViolation
? Colors.red[700]
: (isPending
? Colors.orange[800]
: (isComplete ? Colors.grey[600] : Colors.red[400])),
fontSize: 11.5,
fontWeight: hasViolation ? FontWeight.bold : FontWeight.normal,
),
),
if (hasViolation) ...[
const Spacer(),
SizedBox(
width: double.infinity,
height: 30,
child: ElevatedButton(
onPressed: () => _showAllowDialog(
student['studentId'],
student['studentName'],
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[600],
foregroundColor: Colors.white,
padding: EdgeInsets.zero,
),
child: const Text('반출 허용', style: TextStyle(fontSize: 12)),
),
),
],
],
),
);
}
// -----------------------------------------------------------------------
// 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때)
// -----------------------------------------------------------------------
Widget _buildDesktopBody(int total, int checkedIn, int absent) {
final groups = _controller.studentsByGrade;
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),
_buildPermissionBanner(),
_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: groups.isEmpty
? const Center(
child: Text(
'해당하는 학생이 없습니다.',
style: TextStyle(color: Colors.grey),
),
)
: _buildGradeGroupedList(
groups,
padding: const EdgeInsets.all(20),
),
),
),
],
),
),
),
);
}
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,
),
),
],
);
}
/// 🔓 하교(12시간)/반출 허용 시간 설정으로 지금 전체 반출이 허용 중이면 눈에 띄게 배너로 알려준다.
/// (허용 중일 땐 무단반출을 감지해도 대시보드에 뜨지 않기 때문에, 왜 안 뜨는지 헷갈리지 않게 하기 위함)
Widget _buildPermissionBanner() {
final String? until = _controller.globalPermissionUntil;
if (until == null) return const SizedBox.shrink();
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: Colors.amber[100],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.amber[400]!),
),
child: Row(
children: [
Icon(Icons.lock_open_rounded, color: Colors.amber[800], size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'🔓 지금 전체 반출 허용 중입니다 ($until 까지) — 이 시간 동안은 무단반출 경고가 뜨지 않아요.',
style: TextStyle(
color: Colors.amber[900],
fontWeight: FontWeight.bold,
fontSize: 12.5,
),
),
),
],
),
);
}
/// 🔘 필터 칩버튼 (전체 / 출석자 / 미출석자)
Widget _buildFilterChips() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Row(
children: [
FilterChip(
label: const Text("전체"),
selected: _controller.filterType == "ALL",
onSelected: (_) => _controller.setFilter("ALL"),
),
const SizedBox(width: 8),
FilterChip(
label: const Text("🟢 출석자"),
selected: _controller.filterType == "CHECKED_IN",
onSelected: (_) => _controller.setFilter("CHECKED_IN"),
),
const SizedBox(width: 8),
FilterChip(
label: const Text("🔴 미출석자"),
selected: _controller.filterType == "ABSENT",
onSelected: (_) => _controller.setFilter("ABSENT"),
),
],
),
);
}
}