Compare commits
3
Commits
v1.0.0
...
77ef666e16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77ef666e16 | ||
|
|
c8ed8f2fd2 | ||
|
|
49ab432fe9 |
@@ -1,6 +1,7 @@
|
|||||||
# school_attendance
|
# school_attendance
|
||||||
|
|
||||||
A new Flutter project.
|
학교생활 편의 프로젝트
|
||||||
|
|
||||||
|
|
||||||
## Getting Started
|
## 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"}]}}
|
||||||
@@ -23,6 +23,7 @@ final RegExp _logNamePattern = RegExp(r'^(.*?)\s*\(([^)]*)\)$');
|
|||||||
class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||||
List<dynamic> _roster = []; // 전체 학생 명단 (/api/users)
|
List<dynamic> _roster = []; // 전체 학생 명단 (/api/users)
|
||||||
List<dynamic> _logs = []; // 출석 로그 (/api/logs)
|
List<dynamic> _logs = []; // 출석 로그 (/api/logs)
|
||||||
|
Map<String, dynamic> _activeViolationsByStudentId = {}; // 무단반출 중인 학생 (/api/violations/active)
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
String _filterType = "ALL"; // "ALL", "CHECKED_IN", "ABSENT"
|
String _filterType = "ALL"; // "ALL", "CHECKED_IN", "ABSENT"
|
||||||
@@ -45,17 +46,29 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
http.get(Uri.parse('$baseUrl/api/users')),
|
http.get(Uri.parse('$baseUrl/api/users')),
|
||||||
http.get(Uri.parse('$baseUrl/api/logs')),
|
http.get(Uri.parse('$baseUrl/api/logs')),
|
||||||
|
http.get(Uri.parse('$baseUrl/api/violations/active')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final usersRes = results[0];
|
final usersRes = results[0];
|
||||||
final logsRes = results[1];
|
final logsRes = results[1];
|
||||||
|
final violationsRes = results[2];
|
||||||
|
|
||||||
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));
|
||||||
final logsData = jsonDecode(utf8.decode(logsRes.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(() {
|
setState(() {
|
||||||
_roster = usersData['users'] ?? [];
|
_roster = usersData['users'] ?? [];
|
||||||
_logs = logsData['logs'] ?? [];
|
_logs = logsData['logs'] ?? [];
|
||||||
|
_activeViolationsByStudentId = activeViolations;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -66,6 +79,125 @@ 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('설정하기'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 오늘 날짜 기준으로 학번별 "가장 최근 출석 기록"만 남긴 맵을 만든다.
|
/// 오늘 날짜 기준으로 학번별 "가장 최근 출석 기록"만 남긴 맵을 만든다.
|
||||||
Map<String, Map<String, String>> get _todaysCheckInsByStudentId {
|
Map<String, Map<String, String>> get _todaysCheckInsByStudentId {
|
||||||
final String todayPrefix = DateTime.now().toIso8601String().substring(
|
final String todayPrefix = DateTime.now().toIso8601String().substring(
|
||||||
@@ -100,12 +232,16 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
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];
|
||||||
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'],
|
||||||
|
'hasActiveViolation': violation != null,
|
||||||
|
'violationPocket': violation?['pocket_number'],
|
||||||
|
'violationTime': violation?['time'],
|
||||||
};
|
};
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
@@ -137,14 +273,245 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
backgroundColor: Colors.blue,
|
backgroundColor: Colors.blue,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
elevation: 0,
|
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
|
body: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: Column(
|
: LayoutBuilder(
|
||||||
children: [
|
builder: (context, constraints) {
|
||||||
_buildSummaryCards(totalCount, checkedInCount, absentCount),
|
final bool isWide = constraints.maxWidth >= 800;
|
||||||
_buildFilterChips(),
|
return isWide
|
||||||
Expanded(
|
? _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
|
child: _filteredStudents.isEmpty
|
||||||
? const Center(
|
? const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -152,109 +519,179 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
style: TextStyle(color: Colors.grey),
|
style: TextStyle(color: Colors.grey),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
child: DataTable(
|
||||||
itemCount: _filteredStudents.length,
|
headingRowColor: WidgetStateProperty.all(
|
||||||
itemBuilder: (context, index) {
|
Colors.grey[50],
|
||||||
final student = _filteredStudents[index];
|
),
|
||||||
final bool isCheckedIn = student['isCheckedIn'];
|
columns: const [
|
||||||
return Container(
|
DataColumn(label: Text('상태')),
|
||||||
margin: const EdgeInsets.symmetric(
|
DataColumn(label: Text('학번')),
|
||||||
horizontal: 24,
|
DataColumn(label: Text('이름')),
|
||||||
vertical: 8,
|
DataColumn(label: Text('제출 시간')),
|
||||||
),
|
DataColumn(label: Text('주머니 번호')),
|
||||||
padding: const EdgeInsets.all(18),
|
DataColumn(label: Text('작업')),
|
||||||
decoration: BoxDecoration(
|
],
|
||||||
color: Colors.white,
|
rows: _filteredStudents.map((student) {
|
||||||
borderRadius: BorderRadius.circular(20),
|
final bool isCheckedIn = student['isCheckedIn'];
|
||||||
boxShadow: [
|
final bool hasViolation =
|
||||||
BoxShadow(
|
student['hasActiveViolation'] == true;
|
||||||
color: Colors.black.withValues(alpha: 0.03),
|
return DataRow(
|
||||||
blurRadius: 12,
|
color: hasViolation
|
||||||
offset: const Offset(0, 4),
|
? WidgetStateProperty.all(Colors.red[50])
|
||||||
),
|
: null,
|
||||||
],
|
cells: [
|
||||||
),
|
DataCell(
|
||||||
child: Row(
|
Icon(
|
||||||
children: [
|
hasViolation
|
||||||
Container(
|
? Icons.warning_amber_rounded
|
||||||
padding: const EdgeInsets.all(10),
|
: (isCheckedIn
|
||||||
decoration: BoxDecoration(
|
? Icons.check_circle_rounded
|
||||||
color: (isCheckedIn
|
: Icons.error_rounded),
|
||||||
|
color: hasViolation
|
||||||
|
? Colors.red
|
||||||
|
: (isCheckedIn
|
||||||
? Colors.blue
|
? Colors.blue
|
||||||
: Colors.red)
|
: Colors.red),
|
||||||
.withValues(alpha: 0.1),
|
size: 20,
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
isCheckedIn
|
|
||||||
? Icons.check_circle_rounded
|
|
||||||
: Icons.error_rounded,
|
|
||||||
color: isCheckedIn
|
|
||||||
? Colors.blue
|
|
||||||
: Colors.red,
|
|
||||||
size: 24,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
DataCell(Text('${student['studentId']}')),
|
||||||
Expanded(
|
DataCell(
|
||||||
child: Column(
|
Text(
|
||||||
crossAxisAlignment:
|
'${student['studentName']}',
|
||||||
CrossAxisAlignment.start,
|
style: const TextStyle(
|
||||||
children: [
|
fontWeight: FontWeight.bold,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isCheckedIn &&
|
DataCell(
|
||||||
student['pocketNumber'] != null)
|
Text(
|
||||||
Container(
|
hasViolation
|
||||||
padding: const EdgeInsets.symmetric(
|
? '🚨 무단반출 (${student['violationTime']})'
|
||||||
horizontal: 12,
|
: (isCheckedIn
|
||||||
vertical: 6,
|
? '${student['checkInTime']}'
|
||||||
),
|
: '미제출'),
|
||||||
decoration: BoxDecoration(
|
style: TextStyle(
|
||||||
color: Colors.blue.shade50,
|
color: hasViolation
|
||||||
borderRadius: BorderRadius.circular(20),
|
? Colors.red[700]
|
||||||
border: Border.all(
|
: (isCheckedIn
|
||||||
color: Colors.blue.shade200,
|
? Colors.grey[700]
|
||||||
),
|
: Colors.red[400]),
|
||||||
),
|
fontWeight: hasViolation
|
||||||
child: Text(
|
? FontWeight.bold
|
||||||
student['pocketNumber'],
|
: FontWeight.normal,
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.blueAccent,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
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,16 +135,20 @@ class _TeacherDashboardState extends State<TeacherDashboard> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 📊 [그리드 레이아웃 메뉴] 시후의 카드 컴포넌트 스타일 적용
|
// 📊 [그리드 레이아웃 메뉴] 시후의 카드 컴포넌트 스타일 적용
|
||||||
Padding(
|
// 🖥️ 데스크톱 브라우저에서 카드가 지나치게 커지지 않도록 최대 너비를 제한한다.
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
Center(
|
||||||
child: GridView.count(
|
child: ConstrainedBox(
|
||||||
shrinkWrap: true,
|
constraints: const BoxConstraints(maxWidth: 700),
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
child: Padding(
|
||||||
crossAxisCount: 2,
|
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||||
crossAxisSpacing: 16,
|
child: GridView.count(
|
||||||
mainAxisSpacing: 16,
|
shrinkWrap: true,
|
||||||
childAspectRatio: 0.95,
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
children: [
|
crossAxisCount: 2,
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
childAspectRatio: 0.95,
|
||||||
|
children: [
|
||||||
_buildModernCard(
|
_buildModernCard(
|
||||||
icon: Icons.assignment_turned_in_rounded,
|
icon: Icons.assignment_turned_in_rounded,
|
||||||
title: '실시간 출석 확인',
|
title: '실시간 출석 확인',
|
||||||
@@ -170,7 +174,9 @@ class _TeacherDashboardState extends State<TeacherDashboard> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|||||||
Reference in New Issue
Block a user