Cards flow

This commit is contained in:
2026-05-02 19:38:30 +02:00
parent 11e63f7758
commit 6ddb120c94
11 changed files with 681 additions and 25 deletions

View File

@@ -213,6 +213,10 @@ function TutorApp({ currentUser, onLogout }) {
const [studentProgram, setStudentProgram] = useState(null)
const [activeAssetIndex, setActiveAssetIndex] = useState(0)
const [activeSectionIndex, setActiveSectionIndex] = useState(0)
const [programStart, setProgramStart] = useState({ asset_index: 0, section_index: 0 })
const [cardProgress, setCardProgress] = useState([])
const [sessionCardStates, setSessionCardStates] = useState({})
const [sessionLogs, setSessionLogs] = useState([])
const [progressPanelOpen, setProgressPanelOpen] = useState(false)
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false)
const mediaRecorderRef = useRef(null)
@@ -301,12 +305,18 @@ function TutorApp({ currentUser, onLogout }) {
setExerciseStatus('not-started')
setStudentProgram(null)
setProgramLessons([])
setProgramStart({ asset_index: 0, section_index: 0 })
setCardProgress([])
setSessionCardStates({})
setSessionLogs([])
if (selectedStudentId) {
loadProgress(selectedStudentId)
loadMessages(selectedStudentId)
loadStudentProgram(selectedStudentId)
loadCardProgress(selectedStudentId)
if (!isStudentUser) {
loadProgramStatus()
loadSessionLogs(selectedStudentId)
const student = students.find((item) => String(item.id) === String(selectedStudentId))
if (student) loadProgramLessons(student.grade)
}
@@ -321,11 +331,6 @@ function TutorApp({ currentUser, onLogout }) {
}
}, [selectedVoiceProfileId])
useEffect(() => {
setActiveAssetIndex(0)
setActiveSectionIndex(0)
}, [studentProgram?.id])
useEffect(() => {
loadVoiceProfiles()
return () => {
@@ -334,6 +339,12 @@ function TutorApp({ currentUser, onLogout }) {
}
}, [])
useEffect(() => {
if (selectedStudentId && studentProgram?.id) {
loadCardProgress(selectedStudentId, studentProgram.id)
}
}, [selectedStudentId, studentProgram?.id])
async function loadStudents() {
if (isStudentUser) {
if (currentUser.student) {
@@ -507,7 +518,42 @@ function TutorApp({ currentUser, onLogout }) {
setSpeaking(false)
}
function endSession() {
async function saveLessonSession() {
if (!selectedStudentId || !studentProgram) return
const conversation = messages.map((message) => ({
id: message.id,
role: message.role,
content: message.content,
created_at: message.created_at || null,
}))
if (currentInstruction) {
conversation.push({
id: 'current-instruction',
role: 'assistant',
content: currentInstruction,
created_at: null,
})
}
await apiFetch('/session/end', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
student_id: Number(selectedStudentId),
lesson_id: studentProgram.id,
lesson_title: studentProgram.title,
conversation,
card_states: getMergedCardStates(),
}),
})
await loadCardProgress(selectedStudentId, studentProgram.id)
}
async function endSession() {
try {
await saveLessonSession()
} catch (error) {
setErrorMessage(error.message || 'Impossible denregistrer la séance.')
}
stopSpeechPlayback()
deactivateAutoListening(false)
setSessionActive(false)
@@ -518,6 +564,7 @@ function TutorApp({ currentUser, onLogout }) {
setAssessmentAnswer('')
setInput('')
setLastStudentTransmission('')
setSessionCardStates({})
}
async function toggleSession() {
@@ -568,11 +615,39 @@ function TutorApp({ currentUser, onLogout }) {
try {
const data = await apiFetch(`/students/${studentId}/program`)
setStudentProgram(data.lesson || null)
const nextStart = {
asset_index: data.start_asset_index || 0,
section_index: data.start_section_index || 0,
}
setProgramStart(nextStart)
setActiveAssetIndex(nextStart.asset_index)
setActiveSectionIndex(nextStart.section_index)
} catch (error) {
setErrorMessage(error.message || 'Impossible de charger la leçon de lélève.')
}
}
async function loadCardProgress(studentId, lessonId = studentProgram?.id) {
if (!studentId) return
try {
const suffix = lessonId ? `?lesson_id=${encodeURIComponent(lessonId)}` : ''
const data = await apiFetch(`/students/${studentId}/card-progress${suffix}`)
setCardProgress(data || [])
} catch {
setCardProgress([])
}
}
async function loadSessionLogs(studentId) {
if (!studentId || isStudentUser) return
try {
const data = await apiFetch(`/admin/students/${studentId}/session-logs`)
setSessionLogs(data || [])
} catch {
setSessionLogs([])
}
}
async function assignProgramLesson(lessonId) {
if (!selectedStudentId || !lessonId) return
try {
@@ -583,11 +658,51 @@ function TutorApp({ currentUser, onLogout }) {
body: JSON.stringify({ lesson_id: lessonId }),
})
setStudentProgram(data.lesson || null)
const nextStart = {
asset_index: data.start_asset_index || 0,
section_index: data.start_section_index || 0,
}
setProgramStart(nextStart)
setActiveAssetIndex(nextStart.asset_index)
setActiveSectionIndex(nextStart.section_index)
} catch (error) {
setErrorMessage(error.message || 'Impossible daffecter cette leçon.')
}
}
async function updateProgramStart(assetIndex, sectionIndex) {
if (!selectedStudentId || !studentProgram) return
try {
const data = await apiFetch(`/admin/students/${selectedStudentId}/program/start`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asset_index: Number(assetIndex),
section_index: Number(sectionIndex),
}),
})
setStudentProgram(data.lesson || null)
const nextStart = {
asset_index: data.start_asset_index || 0,
section_index: data.start_section_index || 0,
}
setProgramStart(nextStart)
setActiveAssetIndex(nextStart.asset_index)
setActiveSectionIndex(nextStart.section_index)
} catch (error) {
setErrorMessage(error.message || 'Impossible de programmer la reprise.')
}
}
async function deleteSessionLog(logId) {
try {
await apiFetch(`/admin/session-logs/${logId}`, { method: 'DELETE' })
await loadSessionLogs(selectedStudentId)
} catch (error) {
setErrorMessage(error.message || 'Impossible deffacer ce log.')
}
}
function goToPreviousLessonStep() {
if (activeSectionIndex > 0) {
setActiveSectionIndex((index) => index - 1)
@@ -612,6 +727,62 @@ function TutorApp({ currentUser, onLogout }) {
}
}
function getSectionKey(asset, section) {
return section?.context_key || `${asset?.path || 'asset'}:${section?.index ?? 0}`
}
function getActiveCardState(score = 0, validated = false) {
if (!studentProgram || !activeAsset) return null
const sectionTitle = activeSection?.title || activeAsset.title
const sectionKey = getSectionKey(activeAsset, activeSection)
return {
lesson_id: studentProgram.id,
asset_path: activeAsset.path,
asset_title: activeAsset.title,
section_key: sectionKey,
section_title: sectionTitle,
comprehension_score: score,
attempts: 1,
validated,
}
}
function rememberActiveCard({ score = 100, validated = true } = {}) {
const state = getActiveCardState(score, validated)
if (!state) return
setSessionCardStates((current) => {
const previous = current[state.section_key] || {}
return {
...current,
[state.section_key]: {
...state,
comprehension_score: Math.max(previous.comprehension_score || 0, state.comprehension_score),
attempts: Math.max(previous.attempts || 0, state.attempts),
validated: Boolean(previous.validated || state.validated),
},
}
})
}
function getMergedCardStates() {
const fromProgress = cardProgress
.filter((item) => item.lesson_id === studentProgram?.id)
.map((item) => ({
lesson_id: item.lesson_id,
asset_path: item.asset_path,
asset_title: item.asset_title,
section_key: item.section_key,
section_title: item.section_title,
comprehension_score: item.comprehension_score,
attempts: item.attempts,
validated: Boolean(item.validated_at),
}))
const merged = {}
for (const item of fromProgress) merged[item.section_key] = item
for (const item of Object.values(sessionCardStates)) merged[item.section_key] = item
return Object.values(merged)
}
function getLessonContextPayload() {
return {
lesson_title: activeLessonTitle,
@@ -743,7 +914,10 @@ function TutorApp({ currentUser, onLogout }) {
appendMessage('assistant', data.reply)
setCurrentInstruction(data.reply)
if (data.advance_lesson_step) {
rememberActiveCard({ score: 100, validated: true })
goToNextLessonStep()
} else {
rememberActiveCard({ score: 40, validated: false })
}
speak(data.reply)
}
@@ -1005,6 +1179,12 @@ function TutorApp({ currentUser, onLogout }) {
const lessonStepPercent = totalLessonSteps
? Math.min(100, Math.round((activeLessonStep / totalLessonSteps) * 100))
: 0
const mergedCardStates = getMergedCardStates()
const validatedLessonCards = mergedCardStates.filter((item) => item.validated).length
const lessonComprehensionPercent = totalLessonSteps
? Math.min(100, Math.round((validatedLessonCards / totalLessonSteps) * 100))
: lessonPercent
const displayedLessonPercent = studentProgram ? lessonComprehensionPercent : lessonPercent
const lessonSummary = lastAssistantMessage?.content || 'Aucune ancienne séance trouvée pour le moment. Professeur TOP commencera par une reprise courte.'
const lastStudentWork = lastUserMessage?.content || 'Aucune réponse récente enregistrée.'
const nextLessonAdvice = studentProgram
@@ -1048,11 +1228,11 @@ function TutorApp({ currentUser, onLogout }) {
<article className="student-info-card card">
<div className="today-card-head">
<span className="stage-label">Avancement</span>
<strong>{lessonPercent}%</strong>
<strong>{displayedLessonPercent}%</strong>
</div>
<h2>{activeLessonTitle}</h2>
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${lessonPercent}%` }} />
<div className="progress-fill" style={{ width: `${displayedLessonPercent}%` }} />
</div>
</article>
@@ -1328,6 +1508,36 @@ function TutorApp({ currentUser, onLogout }) {
? `${studentProgram.subject} · ${studentProgram.asset_count} fiche(s)`
: 'Choisis une leçon dans la colonne de gauche.'}
</p>
{studentProgram?.assets?.length > 0 && (
<div className="admin-start-controls">
<label>
<span>Reprendre à la fiche</span>
<select
value={programStart.asset_index}
onChange={(event) => updateProgramStart(event.target.value, 0)}
>
{studentProgram.assets.map((asset, index) => (
<option key={asset.path} value={index}>
{index + 1}. {asset.title}
</option>
))}
</select>
</label>
<label>
<span>Card de départ</span>
<select
value={programStart.section_index}
onChange={(event) => updateProgramStart(programStart.asset_index, event.target.value)}
>
{(studentProgram.assets[programStart.asset_index]?.sections || []).map((section, index) => (
<option key={section.context_key || index} value={index}>
{index + 1}. {section.title}
</option>
))}
</select>
</label>
</div>
)}
</div>
</section>
) : (
@@ -1351,6 +1561,47 @@ function TutorApp({ currentUser, onLogout }) {
</section>
)}
{selectedStudent && (
<section className="admin-preview">
<h2>Compréhension des cards</h2>
{cardProgress.length ? (
<div className="admin-log-list">
{cardProgress.map((item) => (
<article key={item.id} className="admin-log-card">
<strong>{item.section_title}</strong>
<span>{Math.round(item.comprehension_score)}% · essais: {item.attempts}</span>
</article>
))}
</div>
) : (
<p className="muted">Aucune card validée pour le moment.</p>
)}
</section>
)}
{selectedStudent && (
<section className="admin-preview">
<h2>Logs de séance</h2>
{sessionLogs.length ? (
<div className="admin-log-list">
{sessionLogs.map((log) => (
<article key={log.id} className="admin-log-card">
<div>
<strong>{log.lesson_title}</strong>
<span>{new Date(log.ended_at).toLocaleString('fr-FR')}</span>
</div>
<button type="button" className="secondary-button" onClick={() => deleteSessionLog(log.id)}>
Effacer
</button>
</article>
))}
</div>
) : (
<p className="muted">Aucun log enregistré.</p>
)}
</section>
)}
{errorMessage && <p className="muted voice-status visible-status">{errorMessage}</p>}
</main>
</div>

View File

@@ -154,6 +154,19 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
background: #f8fafc;
padding: 1rem;
}
.admin-start-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin-top: 0.9rem;
}
.admin-start-controls label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.9rem;
font-weight: 700;
}
.admin-preview {
display: flex;
flex-direction: column;
@@ -180,6 +193,30 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
border-radius: 12px;
background: white;
}
.admin-log-list {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.admin-log-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #f8fafc;
padding: 0.75rem;
}
.admin-log-card div {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.admin-log-card span {
color: #64748b;
font-size: 0.9rem;
}
.session-user {
margin-left: auto;
display: flex;