new admin
This commit is contained in:
1716
frontend/package-lock.json
generated
Normal file
1716
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 l’arborescence 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 l’arborescence 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 d’enregistrer 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">
|
||||
|
||||
@@ -172,6 +172,129 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.admin-program-browser {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.9fr) minmax(360px, 1.4fr);
|
||||
gap: 1rem;
|
||||
min-height: 520px;
|
||||
}
|
||||
.program-tree-panel,
|
||||
.admin-program-review {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
background: #f8fafc;
|
||||
padding: 1rem;
|
||||
}
|
||||
.program-tree-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.program-tree-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
color: #14213d;
|
||||
padding: 0.55rem 0.65rem;
|
||||
text-align: left;
|
||||
}
|
||||
.program-tree-row.active {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
.program-tree-row span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.program-tree-row em,
|
||||
.program-tree-row strong,
|
||||
.review-pill {
|
||||
border-radius: 999px;
|
||||
padding: 0.16rem 0.45rem;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
font-size: 0.75rem;
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.program-tree-row strong,
|
||||
.review-pill.validated {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.review-pill.needs_changes {
|
||||
background: #ffedd5;
|
||||
color: #9a3412;
|
||||
}
|
||||
.review-pill.blocked {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
.program-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.program-status-grid span,
|
||||
.admin-review-note {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
padding: 0.65rem;
|
||||
}
|
||||
.program-node-preview {
|
||||
min-height: 260px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: white;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.program-node-preview img {
|
||||
width: 100%;
|
||||
max-height: 420px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.admin-review-note p {
|
||||
margin: 0.35rem 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.admin-review-note span {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.admin-review-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.admin-review-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.admin-review-form textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
border: 1px solid #cfd7e6;
|
||||
border-radius: 12px;
|
||||
padding: 0.8rem 1rem;
|
||||
font: inherit;
|
||||
}
|
||||
.admin-asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
@@ -683,7 +806,8 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.admin-header,
|
||||
.admin-detail {
|
||||
.admin-detail,
|
||||
.admin-program-browser {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user