Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77688170b7 | ||
|
|
c17a28bad9 | ||
|
|
9adaba10cd | ||
|
|
77ef666e16 | ||
|
|
c8ed8f2fd2 | ||
|
|
49ab432fe9 |
@@ -1,6 +1,7 @@
|
||||
# school_attendance
|
||||
|
||||
A new Flutter project.
|
||||
학교생활 편의 프로젝트
|
||||
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
||||
+1
-1
@@ -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"}}}}}}
|
||||
{"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"}],"headers":[{"source":"/index.html","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]},{"source":"/flutter_service_worker.js","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]},{"source":"/version.json","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]}]}}
|
||||
@@ -43,10 +43,14 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
_startNfcSession();
|
||||
if (_watchServiceSupported) {
|
||||
// 화면이 열려있는 동안은 실시간으로 위반 알림을 받아 팝업을 띄운다.
|
||||
// 무단 반출이 한 번 감지되면 "신뢰된 감시 세션"은 끝난 것으로 보고,
|
||||
// 다음 태깅은 체크아웃이 아니라 새 출석(재출석)으로 처리되도록 감시 상태를 해제한다.
|
||||
_violationSub = FlutterBackgroundService().on('violation_detected').listen((
|
||||
event,
|
||||
) {
|
||||
if (mounted) _showViolationDialog(event?['pocketNumber']?.toString());
|
||||
if (!mounted) return;
|
||||
setState(() => _isWatching = false);
|
||||
_showViolationDialog(event?['pocketNumber']?.toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -354,22 +358,10 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
"📴 화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.",
|
||||
"📴 화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.\n(폰을 꺼내 다시 태깅하면 반출 처리됩니다)",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white38, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _checkOut,
|
||||
icon: const Icon(Icons.logout_rounded, color: Colors.white70),
|
||||
label: const Text(
|
||||
"폰 회수하고 감시 종료 (테스트용)",
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.white30),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -23,8 +23,10 @@ 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)
|
||||
Timer? _timer;
|
||||
bool _isLoading = true;
|
||||
bool _isRefreshing = false; // 새로고침 버튼 클릭 시 잠깐 도는 표시용
|
||||
String _filterType = "ALL"; // "ALL", "CHECKED_IN", "ABSENT"
|
||||
|
||||
@override
|
||||
@@ -40,22 +42,40 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
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')),
|
||||
]);
|
||||
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_roster = usersData['users'] ?? [];
|
||||
_logs = logsData['logs'] ?? [];
|
||||
_activeViolationsByStudentId = activeViolations;
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
@@ -66,6 +86,151 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚨 선생님이 특정 학생에게 지금부터 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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
_setPermissionWindow(720); // 12시간 (사실상 하교~다음날까지 허용)
|
||||
},
|
||||
child: const Text('하교 처리'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 오늘 날짜 기준으로 학번별 "가장 최근 출석 기록"만 남긴 맵을 만든다.
|
||||
Map<String, Map<String, String>> get _todaysCheckInsByStudentId {
|
||||
final String todayPrefix = DateTime.now().toIso8601String().substring(
|
||||
@@ -100,12 +265,16 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
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,12 +306,60 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
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: _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),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Column(
|
||||
: 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(totalCount, checkedInCount, absentCount),
|
||||
_buildSummaryCards(total, checkedIn, absent),
|
||||
_buildFilterChips(),
|
||||
Expanded(
|
||||
child: _filteredStudents.isEmpty
|
||||
@@ -158,6 +375,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
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,
|
||||
@@ -165,8 +383,11 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
),
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
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),
|
||||
@@ -175,32 +396,36 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: (isCheckedIn
|
||||
? Colors.blue
|
||||
: Colors.red)
|
||||
color: (hasViolation
|
||||
? Colors.red
|
||||
: (isCheckedIn ? Colors.blue : Colors.red))
|
||||
.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
isCheckedIn
|
||||
hasViolation
|
||||
? Icons.warning_amber_rounded
|
||||
: (isCheckedIn
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.error_rounded,
|
||||
color: isCheckedIn
|
||||
? Colors.blue
|
||||
: Colors.red,
|
||||
: Icons.error_rounded),
|
||||
color: hasViolation
|
||||
? Colors.red
|
||||
: (isCheckedIn ? Colors.blue : Colors.red),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${student['studentName']} 학생',
|
||||
@@ -211,21 +436,27 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isCheckedIn
|
||||
hasViolation
|
||||
? '🚨 무단반출 감지! (${student['violationTime']})'
|
||||
: (isCheckedIn
|
||||
? '학번: ${student['studentId']} | 제출시간: ${student['checkInTime']}'
|
||||
: '학번: ${student['studentId']} | 미제출',
|
||||
: '학번: ${student['studentId']} | 미제출'),
|
||||
style: TextStyle(
|
||||
color: isCheckedIn
|
||||
color: hasViolation
|
||||
? Colors.red[700]
|
||||
: (isCheckedIn
|
||||
? Colors.grey[600]
|
||||
: Colors.red[400],
|
||||
: Colors.red[400]),
|
||||
fontSize: 12,
|
||||
fontWeight: hasViolation
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isCheckedIn &&
|
||||
student['pocketNumber'] != null)
|
||||
if (isCheckedIn && student['pocketNumber'] != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
@@ -234,9 +465,8 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Colors.blue.shade200,
|
||||
),
|
||||
border:
|
||||
Border.all(color: Colors.blue.shade200),
|
||||
),
|
||||
child: Text(
|
||||
student['pocketNumber'],
|
||||
@@ -249,11 +479,273 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
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;
|
||||
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),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
DataCell(Text('${student['studentId']}')),
|
||||
DataCell(
|
||||
Text(
|
||||
'${student['studentName']}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,7 +135,11 @@ class _TeacherDashboardState extends State<TeacherDashboard> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 📊 [그리드 레이아웃 메뉴] 시후의 카드 컴포넌트 스타일 적용
|
||||
Padding(
|
||||
// 🖥️ 데스크톱 브라우저에서 카드가 지나치게 커지지 않도록 최대 너비를 제한한다.
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 700),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
child: GridView.count(
|
||||
shrinkWrap: true,
|
||||
@@ -173,6 +177,8 @@ class _TeacherDashboardState extends State<TeacherDashboard> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user