diff --git a/firebase.json b/firebase.json index e66adc1..5e0e0f9 100644 --- a/firebase.json +++ b/firebase.json @@ -1 +1 @@ -{"flutter":{"platforms":{"android":{"default":{"projectId":"school-display-ff28f","appId":"1:137322214849:android:2595999513206bdeb16b40","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"school-display-ff28f","configurations":{"android":"1:137322214849:android:2595999513206bdeb16b40","web":"1:137322214849:web:0423e5c788d95761b16b40"}}}}}} \ No newline at end of file +{"flutter":{"platforms":{"android":{"default":{"projectId":"school-display-ff28f","appId":"1:137322214849:android:2595999513206bdeb16b40","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"school-display-ff28f","configurations":{"android":"1:137322214849:android:2595999513206bdeb16b40","web":"1:137322214849:web:0423e5c788d95761b16b40"}}}}},"hosting":{"public":"build/web","ignore":["firebase.json","**/.*"],"rewrites":[{"source":"**","destination":"/index.html"}]}} \ No newline at end of file diff --git a/lib/screens/teacher_attendance_page.dart b/lib/screens/teacher_attendance_page.dart index 22bc1f1..b62557f 100644 --- a/lib/screens/teacher_attendance_page.dart +++ b/lib/screens/teacher_attendance_page.dart @@ -23,6 +23,7 @@ final RegExp _logNamePattern = RegExp(r'^(.*?)\s*\(([^)]*)\)$'); class _TeacherAttendancePageState extends State { List _roster = []; // 전체 학생 명단 (/api/users) List _logs = []; // 출석 로그 (/api/logs) + Map _activeViolationsByStudentId = {}; // 무단반출 중인 학생 (/api/violations/active) Timer? _timer; bool _isLoading = true; String _filterType = "ALL"; // "ALL", "CHECKED_IN", "ABSENT" @@ -45,17 +46,29 @@ class _TeacherAttendancePageState extends State { 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')), ]); final usersRes = results[0]; final logsRes = results[1]; + final violationsRes = results[2]; if (usersRes.statusCode == 200 && logsRes.statusCode == 200) { final usersData = jsonDecode(utf8.decode(usersRes.bodyBytes)); final logsData = jsonDecode(utf8.decode(logsRes.bodyBytes)); + + Map activeViolations = {}; + if (violationsRes.statusCode == 200) { + final violationsData = jsonDecode(utf8.decode(violationsRes.bodyBytes)); + for (final v in (violationsData['violations'] ?? [])) { + activeViolations[v['student_id'].toString()] = v; + } + } + setState(() { _roster = usersData['users'] ?? []; _logs = logsData['logs'] ?? []; + _activeViolationsByStudentId = activeViolations; _isLoading = false; }); } else { @@ -66,6 +79,125 @@ class _TeacherAttendancePageState extends State { } } + /// 🚨 선생님이 특정 학생에게 지금부터 N분간 반출을 허용한다. + Future _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 _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')), + ); + } + } + + 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('설정하기'), + ), + ], + ), + ); + } + /// 오늘 날짜 기준으로 학번별 "가장 최근 출석 기록"만 남긴 맵을 만든다. Map> get _todaysCheckInsByStudentId { final String todayPrefix = DateTime.now().toIso8601String().substring( @@ -100,12 +232,16 @@ class _TeacherAttendancePageState extends State { return _roster.map((u) { final String id = u['id']?.toString() ?? ''; final checkIn = checkIns[id]; + final violation = _activeViolationsByStudentId[id]; return { 'studentId': id, 'studentName': u['name']?.toString() ?? '', 'isCheckedIn': checkIn != null, 'pocketNumber': checkIn?['pocketNumber'], 'checkInTime': checkIn?['time'], + 'hasActiveViolation': violation != null, + 'violationPocket': violation?['pocket_number'], + 'violationTime': violation?['time'], }; }).toList(); } @@ -137,14 +273,245 @@ class _TeacherAttendancePageState extends State { backgroundColor: Colors.blue, foregroundColor: Colors.white, elevation: 0, + actions: [ + TextButton.icon( + onPressed: _showPermissionWindowDialog, + icon: const Icon(Icons.timer_outlined, color: Colors.white), + label: const Text( + '반출 허용 시간 설정', + style: TextStyle(color: Colors.white), + ), + ), + const SizedBox(width: 8), + ], ), body: _isLoading ? const Center(child: CircularProgressIndicator()) - : Column( - children: [ - _buildSummaryCards(totalCount, checkedInCount, absentCount), - _buildFilterChips(), - Expanded( + : 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; + 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: (hasViolation + ? Colors.red + : (isCheckedIn ? Colors.blue : Colors.red)) + .withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon( + hasViolation + ? Icons.warning_amber_rounded + : (isCheckedIn + ? Icons.check_circle_rounded + : Icons.error_rounded), + color: hasViolation + ? Colors.red + : (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( + hasViolation + ? '🚨 무단반출 감지! (${student['violationTime']})' + : (isCheckedIn + ? '학번: ${student['studentId']} | 제출시간: ${student['checkInTime']}' + : '학번: ${student['studentId']} | 미제출'), + style: TextStyle( + color: hasViolation + ? Colors.red[700] + : (isCheckedIn + ? Colors.grey[600] + : Colors.red[400]), + fontSize: 12, + fontWeight: hasViolation + ? 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( @@ -152,109 +519,179 @@ class _TeacherAttendancePageState extends State { 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 + : 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; + return DataRow( + color: hasViolation + ? WidgetStateProperty.all(Colors.red[50]) + : null, + cells: [ + DataCell( + Icon( + hasViolation + ? Icons.warning_amber_rounded + : (isCheckedIn + ? Icons.check_circle_rounded + : Icons.error_rounded), + color: hasViolation + ? Colors.red + : (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, + : Colors.red), + size: 20, ), ), - 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, - ), - ), - ], + DataCell(Text('${student['studentId']}')), + DataCell( + Text( + '${student['studentName']}', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), ), ), - 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, - ), + DataCell( + Text( + hasViolation + ? '🚨 무단반출 (${student['violationTime']})' + : (isCheckedIn + ? '${student['checkInTime']}' + : '미제출'), + style: TextStyle( + color: hasViolation + ? Colors.red[700] + : (isCheckedIn + ? Colors.grey[700] + : Colors.red[400]), + fontWeight: hasViolation + ? 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, + ), + ), + ], + ), + ], + ), ); } diff --git a/lib/screens/teacher_dashboard.dart b/lib/screens/teacher_dashboard.dart index f51bd69..a1c31c0 100644 --- a/lib/screens/teacher_dashboard.dart +++ b/lib/screens/teacher_dashboard.dart @@ -135,16 +135,20 @@ class _TeacherDashboardState extends State { const SizedBox(height: 16), // 📊 [그리드 레이아웃 메뉴] 시후의 카드 컴포넌트 스타일 적용 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: GridView.count( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - crossAxisCount: 2, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - childAspectRatio: 0.95, - children: [ + // 🖥️ 데스크톱 브라우저에서 카드가 지나치게 커지지 않도록 최대 너비를 제한한다. + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 700), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: 0.95, + children: [ _buildModernCard( icon: Icons.assignment_turned_in_rounded, title: '실시간 출석 확인', @@ -170,7 +174,9 @@ class _TeacherDashboardState extends State { ), ), ), - ], + ], + ), + ), ), ), const SizedBox(height: 32),