new admin

This commit is contained in:
2026-05-05 22:29:04 +02:00
parent 2bf28ae392
commit b1368980f9
128 changed files with 5329 additions and 59 deletions

View File

@@ -94,6 +94,89 @@ function ProgressCard({ item }) {
)
}
function flattenProgramTree(node, depth = 0, rows = []) {
if (!node) return rows
rows.push({ node, depth })
;(node.children || []).forEach((child) => flattenProgramTree(child, depth + 1, rows))
return rows
}
function ProgramReviewPanel({ selectedNode, reviewForm, onReviewChange, onSaveReview }) {
if (!selectedNode) {
return (
<section className="admin-empty">
<h2>Programme</h2>
<p className="muted">Choisis un élément de larborescence pour le relire, le valider ou laisser un commentaire.</p>
</section>
)
}
const status = selectedNode.status || {}
const review = selectedNode.review || {}
return (
<section className="admin-program-review">
<div className="admin-log-detail-head">
<div>
<span className="stage-label">{selectedNode.type}</span>
<h2>{selectedNode.label}</h2>
<p className="muted">{selectedNode.path || selectedNode.key}</p>
</div>
{review.status && <span className={`review-pill ${review.status}`}>{review.status}</span>}
</div>
{Object.keys(status).length > 0 && (
<div className="program-status-grid">
<span>Micro-fiches: {status.micro_fiches ?? 0}</span>
<span>SVG leçon: {status.lesson_svg ?? 0}</span>
<span>Cards: {status.cards ?? 0}</span>
<span>Contextes: {status.contexts ?? 0}</span>
<span>Exercices: {status.text_exercises ?? 0}</span>
<span>SVG exercices: {status.exercise_svg ?? 0}</span>
</div>
)}
{selectedNode.url && (
<div className="program-node-preview">
<img src={selectedNode.url} alt={selectedNode.label} />
</div>
)}
{review.comment && (
<div className="admin-review-note">
<strong>Dernier commentaire</strong>
<p>{review.comment}</p>
<span>{review.reviewer} · {review.updated_at}</span>
</div>
)}
<form className="admin-review-form" onSubmit={onSaveReview}>
<label>
<span>Statut de validation</span>
<select
value={reviewForm.status}
onChange={(event) => onReviewChange({ ...reviewForm, status: event.target.value })}
>
<option value="validated">Validé</option>
<option value="needs_changes">À modifier</option>
<option value="blocked">Bloqué</option>
<option value="draft">Brouillon</option>
</select>
</label>
<label>
<span>Commentaire visible par Codex</span>
<textarea
value={reviewForm.comment}
onChange={(event) => onReviewChange({ ...reviewForm, comment: event.target.value })}
placeholder="Note ce qui dépasse, ce qui est incorrect, ou ce qui doit être régénéré."
rows={5}
/>
</label>
<button type="submit">Enregistrer la revue</button>
</form>
</section>
)
}
function getSupportedRecordingMimeType() {
if (typeof MediaRecorder === 'undefined') return ''
const candidates = [
@@ -210,6 +293,9 @@ function TutorApp({ currentUser, onLogout }) {
const [selectedVoiceProfileId, setSelectedVoiceProfileId] = useState('')
const [programLessons, setProgramLessons] = useState([])
const [programStatus, setProgramStatus] = useState(null)
const [programTree, setProgramTree] = useState(null)
const [selectedProgramNodeKey, setSelectedProgramNodeKey] = useState('')
const [programReviewForm, setProgramReviewForm] = useState({ status: 'needs_changes', comment: '' })
const [studentProgram, setStudentProgram] = useState(null)
const [activeAssetIndex, setActiveAssetIndex] = useState(0)
const [activeSectionIndex, setActiveSectionIndex] = useState(0)
@@ -265,6 +351,11 @@ function TutorApp({ currentUser, onLogout }) {
}, [currentFocusSkill, progress])
const lessonPercent = lessonFocus ? Math.round(lessonFocus.mastery_score) : averageScore
const programRows = useMemo(() => flattenProgramTree(programTree).filter((row) => row.node.type !== 'root'), [programTree])
const selectedProgramNode = useMemo(
() => programRows.find((row) => row.node.key === selectedProgramNodeKey)?.node || null,
[programRows, selectedProgramNodeKey]
)
const exerciseLabel = {
'not-started': 'Pas encore lance',
@@ -275,8 +366,21 @@ function TutorApp({ currentUser, onLogout }) {
useEffect(() => {
loadStudents()
if (!isStudentUser) {
loadProgramTree()
loadProgramStatus()
}
}, [currentUser])
useEffect(() => {
if (selectedProgramNode) {
setProgramReviewForm({
status: selectedProgramNode.review?.status || 'needs_changes',
comment: selectedProgramNode.review?.comment || '',
})
}
}, [selectedProgramNodeKey, selectedProgramNode?.review?.updated_at])
function pushAudioDebug(message) {
if (!DEBUG_AUDIO) return
const timestamp = new Date().toLocaleTimeString('fr-FR', { hour12: false })
@@ -648,6 +752,36 @@ function TutorApp({ currentUser, onLogout }) {
}
}
async function loadProgramTree() {
if (isStudentUser) return
try {
const data = await apiFetch('/admin/program/tree')
setProgramTree(data)
setSelectedProgramNodeKey((current) => current || data.children?.[0]?.key || '')
} catch (error) {
setErrorMessage(error.message || 'Impossible de charger larborescence du programme.')
}
}
async function saveProgramReview(event) {
event.preventDefault()
if (!selectedProgramNode) return
try {
await apiFetch('/admin/program/review', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
node_key: selectedProgramNode.key,
status: programReviewForm.status,
comment: programReviewForm.comment,
}),
})
await loadProgramTree()
} catch (error) {
setErrorMessage(error.message || 'Impossible denregistrer la revue du programme.')
}
}
async function loadStudentProgram(studentId) {
try {
const data = await apiFetch(`/students/${studentId}/program`)
@@ -1644,6 +1778,38 @@ function TutorApp({ currentUser, onLogout }) {
<span>{currentUser.username} · {currentUser.role}</span>
</header>
<section className="admin-program-browser">
<div className="program-tree-panel">
<div className="admin-log-detail-head">
<h2>Arborescence du programme</h2>
<button type="button" className="secondary-button" onClick={loadProgramTree}>
Rafraîchir
</button>
</div>
<div className="program-tree-list">
{programRows.map(({ node, depth }) => (
<button
key={node.key}
type="button"
className={`program-tree-row ${selectedProgramNodeKey === node.key ? 'active' : ''}`}
style={{ paddingLeft: `${0.65 + depth * 1.05}rem` }}
onClick={() => setSelectedProgramNodeKey(node.key)}
>
<span>{node.label}</span>
<em>{node.type}</em>
{node.review?.status && <strong>{node.review.status}</strong>}
</button>
))}
</div>
</div>
<ProgramReviewPanel
selectedNode={selectedProgramNode}
reviewForm={programReviewForm}
onReviewChange={setProgramReviewForm}
onSaveReview={saveProgramReview}
/>
</section>
{selectedStudent ? (
<section className="admin-detail">
<div className="admin-student-card">