Admin cockpit

This commit is contained in:
2026-05-06 19:10:08 +02:00
parent a886b10b0a
commit 7a7994e8b6
2 changed files with 239 additions and 33 deletions

View File

@@ -101,7 +101,25 @@ function flattenProgramTree(node, depth = 0, rows = []) {
return rows
}
function ProgramReviewPanel({ selectedNode, reviewForm, onReviewChange, onSaveReview }) {
function flattenVisibleProgramTree(node, collapsedKeys, depth = 0, rows = []) {
if (!node) return rows
rows.push({ node, depth })
if (!collapsedKeys.has(node.key)) {
;(node.children || []).forEach((child) => flattenVisibleProgramTree(child, collapsedKeys, depth + 1, rows))
}
return rows
}
function collectCollapsibleProgramKeys(node, depth = 0, keys = []) {
if (!node) return keys
if (depth > 0 && node.children?.length) keys.push(node.key)
;(node.children || []).forEach((child) => collectCollapsibleProgramKeys(child, depth + 1, keys))
return keys
}
function ProgramReviewPanel({ selectedNode, reviewForm, onReviewChange, onSaveReview, readOnly = false, onOpenValidation }) {
const [isFullscreen, setIsFullscreen] = useState(false)
if (!selectedNode) {
return (
<section className="admin-empty">
@@ -136,9 +154,10 @@ function ProgramReviewPanel({ selectedNode, reviewForm, onReviewChange, onSaveRe
)}
{selectedNode.url && (
<div className="program-node-preview">
<button type="button" className="program-node-preview" onClick={() => setIsFullscreen(true)}>
<img src={selectedNode.url} alt={selectedNode.label} />
</div>
<span>{readOnly ? 'Cliquer pour inspecter en plein écran' : 'Cliquer pour valider en plein écran'}</span>
</button>
)}
{review.comment && (
@@ -149,30 +168,95 @@ function ProgramReviewPanel({ selectedNode, reviewForm, onReviewChange, onSaveRe
</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>
{readOnly ? (
<div className="admin-review-form">
<strong>Validation centralisée</strong>
<p className="muted">Cet onglet sert à explorer le programme. Les décisions éditoriales se font dans longlet Validation contenus.</p>
<button type="button" onClick={onOpenValidation}>Ouvrir dans Validation contenus</button>
</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>
)}
{isFullscreen && (
<div className="review-fullscreen" role="dialog" aria-modal="true" aria-label={`Revue plein écran ${selectedNode.label}`}>
<div className="review-fullscreen-stage">
<div className="review-fullscreen-head">
<div>
<span className="stage-label">{selectedNode.type}</span>
<h2>{selectedNode.label}</h2>
<p className="muted">{selectedNode.path || selectedNode.key}</p>
</div>
<button type="button" className="secondary-button" onClick={() => setIsFullscreen(false)}>
Fermer
</button>
</div>
<div className="review-fullscreen-image">
<img src={selectedNode.url} alt={selectedNode.label} />
</div>
</div>
<aside className="review-fullscreen-panel">
<h2>{readOnly ? 'Inspection' : 'Validation'}</h2>
{readOnly ? (
<div className="admin-review-form">
<p className="muted">Pour modifier le statut ou laisser une remarque, ouvre cet élément dans la file de validation.</p>
<button type="button" onClick={() => { setIsFullscreen(false); onOpenValidation?.() }}>
Ouvrir dans Validation contenus
</button>
</div>
) : (
<form className="admin-review-form" onSubmit={onSaveReview}>
<label>
<span>Statut</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</span>
<textarea
value={reviewForm.comment}
onChange={(event) => onReviewChange({ ...reviewForm, comment: event.target.value })}
placeholder="Note précisément ce qui doit être corrigé."
rows={8}
/>
</label>
<button type="submit">Enregistrer la revue</button>
</form>
)}
</aside>
</div>
)}
</section>
)
}
@@ -297,6 +381,7 @@ function TutorApp({ currentUser, onLogout }) {
const [selectedProgramNodeKey, setSelectedProgramNodeKey] = useState('')
const [programReviewForm, setProgramReviewForm] = useState({ status: 'needs_changes', comment: '' })
const [activeAdminTab, setActiveAdminTab] = useState('overview')
const [collapsedProgramKeys, setCollapsedProgramKeys] = useState(() => new Set())
const [studentProgram, setStudentProgram] = useState(null)
const [activeAssetIndex, setActiveAssetIndex] = useState(0)
const [activeSectionIndex, setActiveSectionIndex] = useState(0)
@@ -353,6 +438,10 @@ function TutorApp({ currentUser, onLogout }) {
const lessonPercent = lessonFocus ? Math.round(lessonFocus.mastery_score) : averageScore
const programRows = useMemo(() => flattenProgramTree(programTree).filter((row) => row.node.type !== 'root'), [programTree])
const visibleProgramRows = useMemo(
() => flattenVisibleProgramTree(programTree, collapsedProgramKeys).filter((row) => row.node.type !== 'root'),
[programTree, collapsedProgramKeys]
)
const selectedProgramNode = useMemo(
() => programRows.find((row) => row.node.key === selectedProgramNodeKey)?.node || null,
[programRows, selectedProgramNodeKey]
@@ -395,6 +484,20 @@ function TutorApp({ currentUser, onLogout }) {
setActiveSectionIndex(sectionIndex)
}
function toggleProgramNode(node) {
if (!node?.children?.length) {
setSelectedProgramNodeKey(node?.key || '')
return
}
setSelectedProgramNodeKey(node.key)
setCollapsedProgramKeys((current) => {
const next = new Set(current)
if (next.has(node.key)) next.delete(node.key)
else next.add(node.key)
return next
})
}
useEffect(() => {
isRecordingRef.current = isRecording
}, [isRecording])
@@ -759,6 +862,7 @@ function TutorApp({ currentUser, onLogout }) {
const data = await apiFetch('/admin/program/tree')
setProgramTree(data)
setSelectedProgramNodeKey((current) => current || data.children?.[0]?.key || '')
setCollapsedProgramKeys(new Set(collectCollapsibleProgramKeys(data)))
} catch (error) {
setErrorMessage(error.message || 'Impossible de charger larborescence du programme.')
}
@@ -1987,16 +2091,26 @@ function TutorApp({ currentUser, onLogout }) {
<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>
{visibleProgramRows.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={() => toggleProgramNode(node)}>
<span className="tree-label">
<b>{node.children?.length ? (collapsedProgramKeys.has(node.key) ? '+' : '-') : ''}</b>
{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} />
<ProgramReviewPanel
selectedNode={selectedProgramNode}
reviewForm={programReviewForm}
onReviewChange={setProgramReviewForm}
onSaveReview={saveProgramReview}
readOnly
onOpenValidation={() => setActiveAdminTab('validation')}
/>
</section>
)}