Spaces:
Sleeping
Sleeping
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 +12 -0
- src/components/ta/RiskCard.jsx +30 -21
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 |
-
|
| 18 |
-
|
| 19 |
-
|
| 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' }} />
|
| 82 |
</span>
|
| 83 |
)}
|
| 84 |
-
{
|
| 85 |
<span style={{ fontSize: '10px', padding: '2px 6px', borderRadius: '4px', background: 'var(--ta-purple-bg)', color: 'var(--ta-purple)', fontWeight: 600 }}>
|
| 86 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>{
|
| 124 |
</div>
|
| 125 |
<div style={{ height: '6px', background: 'var(--border-primary)', borderRadius: '3px', overflow: 'hidden' }}>
|
| 126 |
<div style={{
|
| 127 |
-
width: `${
|
| 128 |
height: '100%',
|
| 129 |
-
background: getScoreBarColor(
|
| 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>{
|
| 139 |
</div>
|
| 140 |
<div style={{ height: '6px', background: 'var(--border-primary)', borderRadius: '3px', overflow: 'hidden' }}>
|
| 141 |
<div style={{
|
| 142 |
-
width: `${
|
| 143 |
height: '100%',
|
| 144 |
-
background: getScoreBarColor(
|
| 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>{
|
| 154 |
</div>
|
| 155 |
<div style={{ height: '6px', background: 'var(--border-primary)', borderRadius: '3px', overflow: 'hidden' }}>
|
| 156 |
<div style={{
|
| 157 |
-
width: `${
|
| 158 |
height: '100%',
|
| 159 |
-
background: getScoreBarColor(
|
| 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>
|