Admin cockpit
This commit is contained in:
@@ -101,7 +101,25 @@ function flattenProgramTree(node, depth = 0, rows = []) {
|
|||||||
return 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) {
|
if (!selectedNode) {
|
||||||
return (
|
return (
|
||||||
<section className="admin-empty">
|
<section className="admin-empty">
|
||||||
@@ -136,9 +154,10 @@ function ProgramReviewPanel({ selectedNode, reviewForm, onReviewChange, onSaveRe
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedNode.url && (
|
{selectedNode.url && (
|
||||||
<div className="program-node-preview">
|
<button type="button" className="program-node-preview" onClick={() => setIsFullscreen(true)}>
|
||||||
<img src={selectedNode.url} alt={selectedNode.label} />
|
<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 && (
|
{review.comment && (
|
||||||
@@ -149,6 +168,13 @@ function ProgramReviewPanel({ selectedNode, reviewForm, onReviewChange, onSaveRe
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{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 l’onglet Validation contenus.</p>
|
||||||
|
<button type="button" onClick={onOpenValidation}>Ouvrir dans Validation contenus</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<form className="admin-review-form" onSubmit={onSaveReview}>
|
<form className="admin-review-form" onSubmit={onSaveReview}>
|
||||||
<label>
|
<label>
|
||||||
<span>Statut de validation</span>
|
<span>Statut de validation</span>
|
||||||
@@ -173,6 +199,64 @@ function ProgramReviewPanel({ selectedNode, reviewForm, onReviewChange, onSaveRe
|
|||||||
</label>
|
</label>
|
||||||
<button type="submit">Enregistrer la revue</button>
|
<button type="submit">Enregistrer la revue</button>
|
||||||
</form>
|
</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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -297,6 +381,7 @@ function TutorApp({ currentUser, onLogout }) {
|
|||||||
const [selectedProgramNodeKey, setSelectedProgramNodeKey] = useState('')
|
const [selectedProgramNodeKey, setSelectedProgramNodeKey] = useState('')
|
||||||
const [programReviewForm, setProgramReviewForm] = useState({ status: 'needs_changes', comment: '' })
|
const [programReviewForm, setProgramReviewForm] = useState({ status: 'needs_changes', comment: '' })
|
||||||
const [activeAdminTab, setActiveAdminTab] = useState('overview')
|
const [activeAdminTab, setActiveAdminTab] = useState('overview')
|
||||||
|
const [collapsedProgramKeys, setCollapsedProgramKeys] = useState(() => new Set())
|
||||||
const [studentProgram, setStudentProgram] = useState(null)
|
const [studentProgram, setStudentProgram] = useState(null)
|
||||||
const [activeAssetIndex, setActiveAssetIndex] = useState(0)
|
const [activeAssetIndex, setActiveAssetIndex] = useState(0)
|
||||||
const [activeSectionIndex, setActiveSectionIndex] = 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 lessonPercent = lessonFocus ? Math.round(lessonFocus.mastery_score) : averageScore
|
||||||
const programRows = useMemo(() => flattenProgramTree(programTree).filter((row) => row.node.type !== 'root'), [programTree])
|
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(
|
const selectedProgramNode = useMemo(
|
||||||
() => programRows.find((row) => row.node.key === selectedProgramNodeKey)?.node || null,
|
() => programRows.find((row) => row.node.key === selectedProgramNodeKey)?.node || null,
|
||||||
[programRows, selectedProgramNodeKey]
|
[programRows, selectedProgramNodeKey]
|
||||||
@@ -395,6 +484,20 @@ function TutorApp({ currentUser, onLogout }) {
|
|||||||
setActiveSectionIndex(sectionIndex)
|
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(() => {
|
useEffect(() => {
|
||||||
isRecordingRef.current = isRecording
|
isRecordingRef.current = isRecording
|
||||||
}, [isRecording])
|
}, [isRecording])
|
||||||
@@ -759,6 +862,7 @@ function TutorApp({ currentUser, onLogout }) {
|
|||||||
const data = await apiFetch('/admin/program/tree')
|
const data = await apiFetch('/admin/program/tree')
|
||||||
setProgramTree(data)
|
setProgramTree(data)
|
||||||
setSelectedProgramNodeKey((current) => current || data.children?.[0]?.key || '')
|
setSelectedProgramNodeKey((current) => current || data.children?.[0]?.key || '')
|
||||||
|
setCollapsedProgramKeys(new Set(collectCollapsibleProgramKeys(data)))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorMessage(error.message || 'Impossible de charger l’arborescence du programme.')
|
setErrorMessage(error.message || 'Impossible de charger l’arborescence du programme.')
|
||||||
}
|
}
|
||||||
@@ -1987,16 +2091,26 @@ function TutorApp({ currentUser, onLogout }) {
|
|||||||
<button type="button" className="secondary-button" onClick={loadProgramTree}>Rafraîchir</button>
|
<button type="button" className="secondary-button" onClick={loadProgramTree}>Rafraîchir</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="program-tree-list">
|
<div className="program-tree-list">
|
||||||
{programRows.map(({ node, depth }) => (
|
{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={() => setSelectedProgramNodeKey(node.key)}>
|
<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>{node.label}</span>
|
<span className="tree-label">
|
||||||
|
<b>{node.children?.length ? (collapsedProgramKeys.has(node.key) ? '+' : '-') : ''}</b>
|
||||||
|
{node.label}
|
||||||
|
</span>
|
||||||
<em>{node.type}</em>
|
<em>{node.type}</em>
|
||||||
{node.review?.status && <strong>{node.review.status}</strong>}
|
{node.review?.status && <strong>{node.review.status}</strong>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -350,6 +350,67 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.review-fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 30;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 360px;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(248, 250, 252, 0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-fullscreen-stage,
|
||||||
|
.review-fullscreen-panel {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
border: 1px solid #dbe3ef;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.16);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-fullscreen-head {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-fullscreen-head h2,
|
||||||
|
.review-fullscreen-head p,
|
||||||
|
.review-fullscreen-panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-fullscreen-image {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-fullscreen-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-fullscreen-panel .admin-review-form {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 360px 1fr;
|
grid-template-columns: 360px 1fr;
|
||||||
@@ -540,6 +601,23 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.program-tree-row .tree-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
.program-tree-row .tree-label b {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.15rem;
|
||||||
|
min-width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #14213d;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
.program-tree-row em,
|
.program-tree-row em,
|
||||||
.program-tree-row strong,
|
.program-tree-row strong,
|
||||||
.review-pill {
|
.review-pill {
|
||||||
@@ -578,13 +656,16 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|||||||
padding: 0.65rem;
|
padding: 0.65rem;
|
||||||
}
|
}
|
||||||
.program-node-preview {
|
.program-node-preview {
|
||||||
height: min(42vh, 380px);
|
position: relative;
|
||||||
min-height: 280px;
|
width: 100%;
|
||||||
|
height: 340px;
|
||||||
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: white;
|
background: white;
|
||||||
|
color: #14213d;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -595,6 +676,17 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
.program-node-preview span {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(20, 33, 61, 0.82);
|
||||||
|
color: white;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
.admin-review-note p {
|
.admin-review-note p {
|
||||||
margin: 0.35rem 0;
|
margin: 0.35rem 0;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
|
|||||||
Reference in New Issue
Block a user