quachtiensinh27 commited on
Commit
afbe35a
·
1 Parent(s): 910c7cb

feat: thêm hạn nộp quiz và cải thiện at-risk scoring

Browse files

- Thêm field due_at cho quiz với deadline validation
- Cải thiện at-risk scoring: tăng thresholds (warning: 4.0, critical: 7.5)
- Preserves score_breakdown và weights_applied cho UI display
- Hiển thị chi tiết tín hiệu risk: vắng mặt, stuck, unanswered questions, frustration
- Update UI QuizWorkflow với datetime picker cho hạn nộp
- Cải thiện RiskCard hiển thị score breakdown với điểm chi tiết từng nguồn
- Thêm migration files cho changes

src/components/ta/QuizWorkflow.jsx CHANGED
@@ -115,6 +115,7 @@ const QuizWorkflow = ({ quizId, spaceId, onBack, onSave, onSend, onViewResults }
115
  description: quiz.description,
116
  status: quiz.status,
117
  passing_score: quiz.passing_score || 60,
 
118
  questions: questions.map(toQuestionPayload)
119
  });
120
 
@@ -287,6 +288,17 @@ const QuizWorkflow = ({ quizId, spaceId, onBack, onSave, onSend, onViewResults }
287
  <span style={{ fontSize: '24px', fontWeight: 700, color: 'var(--primary)', fontFamily: 'inherit' }}>%</span>
288
  </div>
289
  </div>
 
 
 
 
 
 
 
 
 
 
 
290
  <div>
291
  <label style={{ display: 'block', fontSize: '11px', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: '8px', fontFamily: 'inherit' }}>Trạng thái</label>
292
  <select
 
115
  description: quiz.description,
116
  status: quiz.status,
117
  passing_score: quiz.passing_score || 60,
118
+ due_at: quiz.due_at || null,
119
  questions: questions.map(toQuestionPayload)
120
  });
121
 
 
288
  <span style={{ fontSize: '24px', fontWeight: 700, color: 'var(--primary)', fontFamily: 'inherit' }}>%</span>
289
  </div>
290
  </div>
291
+ <div>
292
+ <label style={{ display: 'block', fontSize: '11px', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: '8px', fontFamily: 'inherit' }}>Hạn nộp</label>
293
+ <input
294
+ className="ta-input"
295
+ type="datetime-local"
296
+ value={quiz.due_at || ''}
297
+ onChange={(e) => setQuiz({ ...quiz, due_at: e.target.value || null })}
298
+ placeholder="Chọn hạn nộp..."
299
+ />
300
+ <div style={{ fontSize: '10px', color: 'var(--text-muted)', marginTop: '4px' }}>Để trống nếu không giới hạn</div>
301
+ </div>
302
  <div>
303
  <label style={{ display: 'block', fontSize: '11px', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: '8px', fontFamily: 'inherit' }}>Trạng thái</label>
304
  <select
src/components/ta/RiskCard.jsx CHANGED
@@ -1,5 +1,5 @@
1
  import React, { useState } from 'react';
2
- import { FiClock, FiMessageSquare, FiCheckCircle, FiUser, FiBarChart2, FiInfo } from 'react-icons/fi';
3
 
4
  const RiskCard = ({ student, spaceName, onResolve, onGetContext, formatOfflineTime }) => {
5
  const [showBreakdown, setShowBreakdown] = useState(false);
@@ -7,17 +7,16 @@ const RiskCard = ({ student, spaceName, onResolve, onGetContext, formatOfflineTi
7
  const score = student.metadata?.score || 0;
8
  const level = student.level || (score >= 6 ? 'critical' : score >= 3 ? 'warning' : 'safe');
9
 
10
- const signals = student.metadata?.signals || {};
11
  const absencePoints = signals.absence_points || 0;
12
  const stuckPoints = signals.stuck_points || 0;
13
  const quizAvg = signals.quiz_avg_score !== undefined ? signals.quiz_avg_score : null;
14
 
15
- // Score breakdown from 3 sources
16
- const breakdown = student.score_breakdown || {
17
- last_seen_normalized: 0,
18
- fastapi_normalized: 0,
19
- quiz_normalized: 0
20
- };
21
 
22
  const riskColor = level === 'critical' ? 'var(--ta-red)' : level === 'warning' ? 'var(--ta-amber)' : 'var(--ta-green)';
23
  const riskLabel = level === 'critical' ? 'Nghiêm trọng' : level === 'warning' ? 'Cần chú ý' : 'An toàn';
@@ -73,17 +72,27 @@ const RiskCard = ({ student, spaceName, onResolve, onGetContext, formatOfflineTi
73
  <div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
74
  {absencePoints > 0 && (
75
  <span style={{ fontSize: '10px', padding: '2px 6px', borderRadius: '4px', background: 'var(--ta-amber-bg)', color: 'var(--ta-amber)', fontWeight: 600 }}>
76
- <FiClock size={10} style={{ verticalAlign: 'middle', marginRight: '2px' }} />Vắng {Math.floor(student.hours_since_active || 0)}h
77
  </span>
78
  )}
79
  {stuckPoints > 0 && (
80
  <span style={{ fontSize: '10px', padding: '2px 6px', borderRadius: '4px', background: 'var(--ta-blue-bg)', color: 'var(--ta-blue)', fontWeight: 600 }}>
81
- <FiMessageSquare size={10} style={{ verticalAlign: 'middle', marginRight: '2px' }} />Khó khăn
82
  </span>
83
  )}
84
- {quizAvg !== null && quizAvg < 70 && (
85
  <span style={{ fontSize: '10px', padding: '2px 6px', borderRadius: '4px', background: 'var(--ta-purple-bg)', color: 'var(--ta-purple)', fontWeight: 600 }}>
86
- <FiBarChart2 size={10} style={{ verticalAlign: 'middle', marginRight: '2px' }} />Điểm {quizAvg.toFixed(0)}
 
 
 
 
 
 
 
 
 
 
87
  </span>
88
  )}
89
  </div>
@@ -120,13 +129,13 @@ const RiskCard = ({ student, spaceName, onResolve, onGetContext, formatOfflineTi
120
  <div style={{ marginBottom: '6px' }}>
121
  <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', marginBottom: '2px' }}>
122
  <span>Vắng mặt</span>
123
- <span>{breakdown.last_seen_normalized?.toFixed(1)} / 10</span>
124
  </div>
125
  <div style={{ height: '6px', background: 'var(--border-primary)', borderRadius: '3px', overflow: 'hidden' }}>
126
  <div style={{
127
- width: `${(breakdown.last_seen_normalized || 0) * 10}%`,
128
  height: '100%',
129
- background: getScoreBarColor(breakdown.last_seen_normalized || 0, 'last_seen')
130
  }} />
131
  </div>
132
  </div>
@@ -135,13 +144,13 @@ const RiskCard = ({ student, spaceName, onResolve, onGetContext, formatOfflineTi
135
  <div style={{ marginBottom: '6px' }}>
136
  <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', marginBottom: '2px' }}>
137
  <span>Tín hiệu chat</span>
138
- <span>{breakdown.fastapi_normalized?.toFixed(1)} / 10</span>
139
  </div>
140
  <div style={{ height: '6px', background: 'var(--border-primary)', borderRadius: '3px', overflow: 'hidden' }}>
141
  <div style={{
142
- width: `${(breakdown.fastapi_normalized || 0) * 10}%`,
143
  height: '100%',
144
- background: getScoreBarColor(breakdown.fastapi_normalized || 0, 'fastapi')
145
  }} />
146
  </div>
147
  </div>
@@ -150,13 +159,13 @@ const RiskCard = ({ student, spaceName, onResolve, onGetContext, formatOfflineTi
150
  <div style={{ marginBottom: '8px' }}>
151
  <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', marginBottom: '2px' }}>
152
  <span>Điểm Quiz</span>
153
- <span>{breakdown.quiz_normalized?.toFixed(1)} / 10</span>
154
  </div>
155
  <div style={{ height: '6px', background: 'var(--border-primary)', borderRadius: '3px', overflow: 'hidden' }}>
156
  <div style={{
157
- width: `${(breakdown.quiz_normalized || 0) * 10}%`,
158
  height: '100%',
159
- background: getScoreBarColor(breakdown.quiz_normalized || 0, 'quiz')
160
  }} />
161
  </div>
162
  </div>
 
1
  import React, { useState } from 'react';
2
+ import { FiClock, FiMessageSquare, FiCheckCircle, FiUser, FiBarChart2, FiInfo, FiZap } from 'react-icons/fi';
3
 
4
  const RiskCard = ({ student, spaceName, onResolve, onGetContext, formatOfflineTime }) => {
5
  const [showBreakdown, setShowBreakdown] = useState(false);
 
7
  const score = student.metadata?.score || 0;
8
  const level = student.level || (score >= 6 ? 'critical' : score >= 3 ? 'warning' : 'safe');
9
 
10
+ const signals = student.metadata?.signal_details || student.metadata?.signals || {};
11
  const absencePoints = signals.absence_points || 0;
12
  const stuckPoints = signals.stuck_points || 0;
13
  const quizAvg = signals.quiz_avg_score !== undefined ? signals.quiz_avg_score : null;
14
 
15
+ // Score breakdown from 3 sources - use nullish coalescing for missing fields
16
+ const breakdown = student.score_breakdown || {};
17
+ const lastSeenNormalized = breakdown.last_seen_normalized ?? 0;
18
+ const fastApiNormalized = breakdown.fastapi_normalized ?? 0;
19
+ const quizNormalized = breakdown.quiz_normalized ?? 0;
 
20
 
21
  const riskColor = level === 'critical' ? 'var(--ta-red)' : level === 'warning' ? 'var(--ta-amber)' : 'var(--ta-green)';
22
  const riskLabel = level === 'critical' ? 'Nghiêm trọng' : level === 'warning' ? 'Cần chú ý' : 'An toàn';
 
72
  <div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
73
  {absencePoints > 0 && (
74
  <span style={{ fontSize: '10px', padding: '2px 6px', borderRadius: '4px', background: 'var(--ta-amber-bg)', color: 'var(--ta-amber)', fontWeight: 600 }}>
75
+ <FiClock size={10} style={{ verticalAlign: 'middle', marginRight: '2px' }} />Vắng {Math.floor(student.hours_since_active || 0)}h ({absencePoints}đ)
76
  </span>
77
  )}
78
  {stuckPoints > 0 && (
79
  <span style={{ fontSize: '10px', padding: '2px 6px', borderRadius: '4px', background: 'var(--ta-blue-bg)', color: 'var(--ta-blue)', fontWeight: 600 }}>
80
+ <FiMessageSquare size={10} style={{ verticalAlign: 'middle', marginRight: '2px' }} />Stuck ({stuckPoints}đ)
81
  </span>
82
  )}
83
+ {signals.unanswered_q_points > 0 && (
84
  <span style={{ fontSize: '10px', padding: '2px 6px', borderRadius: '4px', background: 'var(--ta-purple-bg)', color: 'var(--ta-purple)', fontWeight: 600 }}>
85
+ <FiMessageSquare size={10} style={{ verticalAlign: 'middle', marginRight: '2px' }} />Chưa trả lời ({signals.unanswered_q_points}đ)
86
+ </span>
87
+ )}
88
+ {signals.frustration_points > 0 && (
89
+ <span style={{ fontSize: '10px', padding: '2px 6px', borderRadius: '4px', background: 'var(--ta-red-bg)', color: 'var(--ta-red)', fontWeight: 600 }}>
90
+ <FiZap size={10} style={{ verticalAlign: 'middle', marginRight: '2px' }} />Frustration ({signals.frustration_points}đ)
91
+ </span>
92
+ )}
93
+ {quizAvg !== null && quizAvg < 70 && (
94
+ <span style={{ fontSize: '10px', padding: '2px 6px', borderRadius: '4px', background: 'var(--ta-green-bg)', color: 'var(--ta-green)', fontWeight: 600 }}>
95
+ <FiBarChart2 size={10} style={{ verticalAlign: 'middle', marginRight: '2px' }} />Quiz: {quizAvg.toFixed(0)}
96
  </span>
97
  )}
98
  </div>
 
129
  <div style={{ marginBottom: '6px' }}>
130
  <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', marginBottom: '2px' }}>
131
  <span>Vắng mặt</span>
132
+ <span>{lastSeenNormalized.toFixed(1)} / 10</span>
133
  </div>
134
  <div style={{ height: '6px', background: 'var(--border-primary)', borderRadius: '3px', overflow: 'hidden' }}>
135
  <div style={{
136
+ width: `${lastSeenNormalized * 10}%`,
137
  height: '100%',
138
+ background: getScoreBarColor(lastSeenNormalized, 'last_seen')
139
  }} />
140
  </div>
141
  </div>
 
144
  <div style={{ marginBottom: '6px' }}>
145
  <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', marginBottom: '2px' }}>
146
  <span>Tín hiệu chat</span>
147
+ <span>{fastApiNormalized.toFixed(1)} / 10</span>
148
  </div>
149
  <div style={{ height: '6px', background: 'var(--border-primary)', borderRadius: '3px', overflow: 'hidden' }}>
150
  <div style={{
151
+ width: `${fastApiNormalized * 10}%`,
152
  height: '100%',
153
+ background: getScoreBarColor(fastApiNormalized, 'fastapi')
154
  }} />
155
  </div>
156
  </div>
 
159
  <div style={{ marginBottom: '8px' }}>
160
  <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', marginBottom: '2px' }}>
161
  <span>Điểm Quiz</span>
162
+ <span>{quizNormalized.toFixed(1)} / 10</span>
163
  </div>
164
  <div style={{ height: '6px', background: 'var(--border-primary)', borderRadius: '3px', overflow: 'hidden' }}>
165
  <div style={{
166
+ width: `${quizNormalized * 10}%`,
167
  height: '100%',
168
+ background: getScoreBarColor(quizNormalized, 'quiz')
169
  }} />
170
  </div>
171
  </div>