Admin cockpit
This commit is contained in:
@@ -296,6 +296,7 @@ function TutorApp({ currentUser, onLogout }) {
|
||||
const [programTree, setProgramTree] = useState(null)
|
||||
const [selectedProgramNodeKey, setSelectedProgramNodeKey] = useState('')
|
||||
const [programReviewForm, setProgramReviewForm] = useState({ status: 'needs_changes', comment: '' })
|
||||
const [activeAdminTab, setActiveAdminTab] = useState('overview')
|
||||
const [studentProgram, setStudentProgram] = useState(null)
|
||||
const [activeAssetIndex, setActiveAssetIndex] = useState(0)
|
||||
const [activeSectionIndex, setActiveSectionIndex] = useState(0)
|
||||
@@ -1682,6 +1683,452 @@ function TutorApp({ currentUser, onLogout }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (!isStudentUser) {
|
||||
const validatedCards = cardProgress.filter((item) => item.validated).length
|
||||
const averageComprehension = cardProgress.length
|
||||
? Math.round(cardProgress.reduce((sum, item) => sum + item.comprehension_score, 0) / cardProgress.length)
|
||||
: 0
|
||||
const reviewRows = programRows.filter(({ node }) => node.type === 'lesson' || node.type === 'fiche' || node.type === 'card')
|
||||
const reviewQueue = reviewRows.filter(({ node }) => ['needs_changes', 'blocked', 'draft', undefined].includes(node.review?.status))
|
||||
const selectedNodeStatus = selectedProgramNode?.status || {}
|
||||
const totalLessonCards = studentProgram?.assets?.reduce((sum, asset) => sum + (asset.sections?.length || 0), 0) || 0
|
||||
const remainingCards = Math.max(totalLessonCards - validatedCards, 0)
|
||||
const adminTabs = [
|
||||
{ id: 'overview', label: 'Vue d’ensemble' },
|
||||
{ id: 'students', label: 'Élèves' },
|
||||
{ id: 'program', label: 'Programme global' },
|
||||
{ id: 'validation', label: 'Validation contenus' },
|
||||
{ id: 'factory', label: 'Atelier génération' },
|
||||
{ id: 'settings', label: 'Paramètres globaux' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="admin-console">
|
||||
<header className="admin-console-header">
|
||||
<div>
|
||||
<p className="eyebrow">Poste de pilotage</p>
|
||||
<h1>Professeur TOP</h1>
|
||||
<p className="muted">Suivi des élèves, validation des contenus et pilotage du programme.</p>
|
||||
</div>
|
||||
<div className="admin-user-box">
|
||||
<span>{currentUser.username} · {currentUser.role}</span>
|
||||
<button type="button" className="secondary-button" onClick={onLogout}>Déconnexion</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="admin-tabs" aria-label="Navigation admin">
|
||||
{adminTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={activeAdminTab === tab.id ? 'active' : ''}
|
||||
onClick={() => setActiveAdminTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<main className="admin-workspace">
|
||||
{activeAdminTab === 'overview' && (
|
||||
<>
|
||||
<section className="admin-metrics-grid">
|
||||
<article className="admin-metric">
|
||||
<span>Élèves actifs</span>
|
||||
<strong>{students.length}</strong>
|
||||
<p>{selectedStudent ? `${selectedStudent.first_name} sélectionné` : 'Aucun élève sélectionné'}</p>
|
||||
</article>
|
||||
<article className="admin-metric">
|
||||
<span>Leçon active</span>
|
||||
<strong>{studentProgram?.title || 'À affecter'}</strong>
|
||||
<p>{studentProgram ? `${studentProgram.asset_count} fiche(s) · ${totalLessonCards} cards` : 'Passe par l’onglet Élèves'}</p>
|
||||
</article>
|
||||
<article className="admin-metric">
|
||||
<span>Compréhension</span>
|
||||
<strong>{averageComprehension}%</strong>
|
||||
<p>{validatedCards} card(s) validée(s), {remainingCards} restante(s)</p>
|
||||
</article>
|
||||
<article className="admin-metric warning">
|
||||
<span>File de revue</span>
|
||||
<strong>{reviewQueue.length}</strong>
|
||||
<p>Contenus à relire, modifier ou intégrer</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="admin-dashboard-grid">
|
||||
<div className="admin-panel">
|
||||
<div className="admin-panel-head">
|
||||
<div>
|
||||
<span className="stage-label">Priorités</span>
|
||||
<h2>À traiter maintenant</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-action-list">
|
||||
<button type="button" onClick={() => setActiveAdminTab('validation')}>
|
||||
Relire les SVG d’exercices fractions
|
||||
<span>Phase 6B attendue</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => setActiveAdminTab('students')}>
|
||||
Vérifier le programme de l’élève
|
||||
<span>{selectedStudent?.first_name || 'Choisir un élève'}</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => setActiveAdminTab('factory')}>
|
||||
Préparer les prochaines générations
|
||||
<span>Kits adaptatifs et intégration</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<div className="admin-panel-head">
|
||||
<div>
|
||||
<span className="stage-label">Production</span>
|
||||
<h2>État du programme</h2>
|
||||
</div>
|
||||
<button type="button" className="secondary-button" onClick={loadProgramTree}>Rafraîchir</button>
|
||||
</div>
|
||||
<div className="program-status-grid">
|
||||
<span>Leçons: {programStatus?.lesson_count ?? 0}</span>
|
||||
<span>CM1: {programStatus?.lessons_by_grade?.CM1 ?? 0}</span>
|
||||
<span>Dossier: {programStatus?.content_root_exists === false ? 'absent' : 'OK'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeAdminTab === 'students' && (
|
||||
<section className="admin-students-layout">
|
||||
<aside className="admin-panel">
|
||||
<div className="admin-panel-head">
|
||||
<div>
|
||||
<span className="stage-label">Élèves</span>
|
||||
<h2>Sélection et création</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="student-roster">
|
||||
{students.map((student) => (
|
||||
<button
|
||||
key={student.id}
|
||||
type="button"
|
||||
className={String(student.id) === String(selectedStudentId) ? 'active' : ''}
|
||||
onClick={() => setSelectedStudentId(String(student.id))}
|
||||
>
|
||||
<strong>{student.first_name}</strong>
|
||||
<span>{student.grade} · {student.age} ans</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={createStudent} className="student-create-form">
|
||||
<h3>Créer un compte élève</h3>
|
||||
<input placeholder="Prénom" value={form.first_name} onChange={(event) => setForm({ ...form, first_name: event.target.value })} />
|
||||
<input type="number" min="8" max="12" value={form.age} onChange={(event) => setForm({ ...form, age: event.target.value })} />
|
||||
<select value={form.grade} onChange={(event) => setForm({ ...form, grade: event.target.value })}>
|
||||
<option>CE2</option>
|
||||
<option>CM1</option>
|
||||
<option>CM2</option>
|
||||
<option>6e</option>
|
||||
</select>
|
||||
<input placeholder="Identifiant élève" value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} />
|
||||
<input placeholder="Mot de passe élève" type="password" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} />
|
||||
<button type="submit">Créer le compte</button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<section className="admin-panel student-report-panel">
|
||||
{selectedStudent ? (
|
||||
<>
|
||||
<div className="admin-panel-head">
|
||||
<div>
|
||||
<span className="stage-label">Fiche élève</span>
|
||||
<h2>{selectedStudent.first_name}</h2>
|
||||
<p className="muted">{selectedStudent.grade} · {selectedStudent.age} ans</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="student-report-grid">
|
||||
<article>
|
||||
<span>Leçon courante</span>
|
||||
<strong>{studentProgram?.title || 'Aucune leçon'}</strong>
|
||||
<p>{studentProgram ? `${studentProgram.subject} · ${studentProgram.asset_count} fiche(s)` : 'Affecte une leçon ci-dessous.'}</p>
|
||||
</article>
|
||||
<article>
|
||||
<span>Résultats</span>
|
||||
<strong>{averageComprehension}%</strong>
|
||||
<p>{cardProgress.length} card(s) observée(s)</p>
|
||||
</article>
|
||||
<article>
|
||||
<span>Contenu fait</span>
|
||||
<strong>{validatedCards}</strong>
|
||||
<p>{remainingCards} card(s) encore à travailler</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="student-settings-grid">
|
||||
<label>
|
||||
<span>Leçon active</span>
|
||||
<select value={studentProgram?.id || ''} onChange={(event) => assignProgramLesson(event.target.value)}>
|
||||
<option value="">Choisir une leçon pour {selectedStudent.grade}</option>
|
||||
{programLessons.map((lesson) => (
|
||||
<option key={lesson.id} value={lesson.id}>{lesson.subject} · {lesson.title} · {lesson.asset_count} fiche(s)</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{studentProgram?.assets?.length > 0 && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<label>
|
||||
<span>Rythme pédagogique</span>
|
||||
<select defaultValue="normal">
|
||||
<option value="slow">Très guidé</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="fast">Autonome</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="wide-field">
|
||||
<span>Instructions propres à l’enfant</span>
|
||||
<textarea placeholder="Exemple: privilégier les exemples concrets, reformuler les consignes longues, valoriser les réponses orales." />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="admin-dashboard-grid">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<div className="admin-log-actions">
|
||||
<button type="button" className="secondary-button" onClick={() => setSelectedSessionLog(log)}>Voir</button>
|
||||
<button type="button" className="secondary-button" onClick={() => deleteSessionLog(log.id)}>Effacer</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="muted">Aucun log enregistré.</p>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{selectedSessionLog && (
|
||||
<section className="admin-preview">
|
||||
<div className="admin-log-detail-head">
|
||||
<h2>Détail de séance</h2>
|
||||
<button type="button" className="secondary-button" onClick={() => setSelectedSessionLog(null)}>Fermer</button>
|
||||
</div>
|
||||
<div className="admin-log-detail">
|
||||
<h3>Résultats des cards</h3>
|
||||
{parseLogJson(selectedSessionLog.card_results).map((item) => (
|
||||
<article key={item.section_key} className="admin-log-card">
|
||||
<strong>{item.section_title}</strong>
|
||||
<span>{Math.round(item.comprehension_score)}% · {item.validated ? 'validée' : 'vue/tentée'}</span>
|
||||
</article>
|
||||
))}
|
||||
<h3>Conversation</h3>
|
||||
{parseLogJson(selectedSessionLog.conversation).map((message, index) => (
|
||||
<p key={`${message.id || index}`} className="admin-log-message">
|
||||
<strong>{message.role === 'assistant' ? 'Professeur TOP' : 'Élève'}:</strong> {message.content}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<section className="admin-empty">
|
||||
<h2>Aucun élève sélectionné</h2>
|
||||
<p className="muted">Choisis un élève ou crée un compte pour configurer son programme.</p>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeAdminTab === 'program' && (
|
||||
<section className="admin-program-browser program-tab">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{activeAdminTab === 'validation' && (
|
||||
<section className="validation-layout">
|
||||
<aside className="admin-panel review-queue">
|
||||
<div className="admin-panel-head">
|
||||
<div>
|
||||
<span className="stage-label">File éditoriale</span>
|
||||
<h2>Contenus à valider</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="review-filters">
|
||||
<span>À relire</span>
|
||||
<span>À modifier</span>
|
||||
<span>Validé</span>
|
||||
<span>Intégré</span>
|
||||
</div>
|
||||
<div className="program-tree-list">
|
||||
{reviewRows.map(({ node, depth }) => (
|
||||
<button key={node.key} type="button" className={`program-tree-row ${selectedProgramNodeKey === node.key ? 'active' : ''}`} style={{ paddingLeft: `${0.65 + Math.min(depth, 3) * 0.75}rem` }} onClick={() => setSelectedProgramNodeKey(node.key)}>
|
||||
<span>{node.label}</span>
|
||||
<em>{node.type}</em>
|
||||
<strong>{node.review?.status || 'à relire'}</strong>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="admin-panel validation-stage">
|
||||
<div className="admin-panel-head">
|
||||
<div>
|
||||
<span className="stage-label">{selectedProgramNode?.type || 'Sélection'}</span>
|
||||
<h2>{selectedProgramNode?.label || 'Choisis une card ou une fiche'}</h2>
|
||||
<p className="muted">{selectedProgramNode?.path || selectedProgramNode?.key || 'La zone centrale affiche le contenu à analyser en grand.'}</p>
|
||||
</div>
|
||||
{selectedProgramNode?.review?.status && <span className={`review-pill ${selectedProgramNode.review.status}`}>{selectedProgramNode.review.status}</span>}
|
||||
</div>
|
||||
<div className="validation-main-preview">
|
||||
{selectedProgramNode?.url ? (
|
||||
<img src={selectedProgramNode.url} alt={selectedProgramNode.label} />
|
||||
) : (
|
||||
<div className="empty-preview">Aucun visuel direct pour cet élément.</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="admin-panel validation-inspector">
|
||||
<h2>Revue</h2>
|
||||
<div className="program-status-grid compact">
|
||||
<span>Micro: {selectedNodeStatus.micro_fiches ?? '-'}</span>
|
||||
<span>SVG: {selectedNodeStatus.lesson_svg ?? '-'}</span>
|
||||
<span>Cards: {selectedNodeStatus.cards ?? '-'}</span>
|
||||
<span>Contextes: {selectedNodeStatus.contexts ?? '-'}</span>
|
||||
<span>Exercices: {selectedNodeStatus.text_exercises ?? '-'}</span>
|
||||
<span>SVG ex.: {selectedNodeStatus.exercise_svg ?? '-'}</span>
|
||||
</div>
|
||||
<form className="admin-review-form" onSubmit={saveProgramReview}>
|
||||
<label>
|
||||
<span>Statut</span>
|
||||
<select value={programReviewForm.status} onChange={(event) => setProgramReviewForm({ ...programReviewForm, 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>Remarque pour modification</span>
|
||||
<textarea value={programReviewForm.comment} onChange={(event) => setProgramReviewForm({ ...programReviewForm, comment: event.target.value })} placeholder="Explique ce qui doit être corrigé avant assimilation au programme." rows={8} />
|
||||
</label>
|
||||
<button type="submit" disabled={!selectedProgramNode}>Enregistrer la revue</button>
|
||||
</form>
|
||||
</aside>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeAdminTab === 'factory' && (
|
||||
<section className="admin-dashboard-grid">
|
||||
<div className="admin-panel">
|
||||
<span className="stage-label">Atelier</span>
|
||||
<h2>Planifier une génération</h2>
|
||||
<div className="student-settings-grid single">
|
||||
<label><span>Niveau</span><select defaultValue="CM1"><option>CM1</option><option>CM2</option><option>6e</option></select></label>
|
||||
<label><span>Production</span><select defaultValue="exercise_svg"><option value="micro">Micro-fiches</option><option value="lesson_svg">SVG leçon</option><option value="contexts">Contextes de cards</option><option value="exercises">Exercices textuels</option><option value="exercise_svg">SVG exercices</option><option value="adaptive">Kit adaptatif</option></select></label>
|
||||
<label><span>Créneau</span><select defaultValue="night"><option value="now">Lancer maintenant</option><option value="night">Cette nuit</option></select></label>
|
||||
<button type="button" disabled>Planifier après branchement backend</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-panel">
|
||||
<span className="stage-label">Pipeline</span>
|
||||
<h2>Assimilation des contenus</h2>
|
||||
<div className="pipeline-list">
|
||||
<span>1. Génération nocturne</span>
|
||||
<span>2. File de validation</span>
|
||||
<span>3. Remarque ou validation</span>
|
||||
<span>4. Assimilation au programme</span>
|
||||
<span>5. Disponibilité dans le mode élève</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeAdminTab === 'settings' && (
|
||||
<section className="admin-dashboard-grid">
|
||||
<div className="admin-panel">
|
||||
<span className="stage-label">Application</span>
|
||||
<h2>Paramètres globaux</h2>
|
||||
<div className="student-settings-grid single">
|
||||
<label><span>Voix par défaut</span><select value={selectedVoiceProfileId} onChange={(event) => setSelectedVoiceProfileId(event.target.value)}>{voiceProfiles.map((profile) => <option key={profile.id} value={profile.id}>{formatVoiceLabel(profile)}</option>)}</select></label>
|
||||
<label><span>Longueur des tours</span><select defaultValue="short"><option value="short">Courte</option><option value="normal">Normale</option></select></label>
|
||||
<label className="wide-field"><span>Règles pédagogiques globales</span><textarea placeholder="Règles communes appliquées à Professeur TOP pour tous les élèves." /></label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-panel">
|
||||
<span className="stage-label">Système</span>
|
||||
<h2>Diagnostic</h2>
|
||||
<div className="program-status-grid">
|
||||
<span>API: active</span>
|
||||
<span>Contenus: {programStatus?.content_root_exists === false ? 'à vérifier' : 'OK'}</span>
|
||||
<span>Leçons: {programStatus?.lesson_count ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{errorMessage && <p className="muted voice-status visible-status">{errorMessage}</p>}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`layout ${isStudentUser ? 'student-layout' : 'admin-layout'}`}>
|
||||
<aside className="sidebar card">
|
||||
|
||||
@@ -28,6 +28,328 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
color: #14213d;
|
||||
}
|
||||
|
||||
.admin-console {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: 0.85rem;
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
.admin-console-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.15rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 18px;
|
||||
background: white;
|
||||
box-shadow: 0 10px 30px rgba(20,33,61,0.07);
|
||||
}
|
||||
|
||||
.admin-console-header h1,
|
||||
.admin-console-header p,
|
||||
.admin-panel h2,
|
||||
.admin-panel h3,
|
||||
.admin-metric p,
|
||||
.admin-metric span,
|
||||
.admin-action-list button,
|
||||
.student-report-grid p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-user-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.admin-tabs button {
|
||||
flex: 0 0 auto;
|
||||
background: transparent;
|
||||
color: #475569;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem 0.9rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.admin-tabs button.active {
|
||||
color: #14213d;
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.admin-workspace {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-metrics-grid,
|
||||
.admin-dashboard-grid,
|
||||
.student-report-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-dashboard-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-metric,
|
||||
.admin-panel {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 18px;
|
||||
background: white;
|
||||
box-shadow: 0 10px 30px rgba(20,33,61,0.06);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.admin-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-height: 132px;
|
||||
}
|
||||
|
||||
.admin-metric span,
|
||||
.student-report-grid span {
|
||||
color: #2563eb;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-metric strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #14213d;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1.1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-metric.warning {
|
||||
background: #fff7ed;
|
||||
border-color: #fed7aa;
|
||||
}
|
||||
|
||||
.admin-panel,
|
||||
.student-report-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.admin-panel-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-action-list,
|
||||
.student-roster,
|
||||
.student-create-form,
|
||||
.pipeline-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.admin-action-list button,
|
||||
.student-roster button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
color: #14213d;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-action-list button span,
|
||||
.student-roster button span {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.student-roster button.active {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.admin-students-layout {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 340px minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.student-create-form {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.student-report-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.student-report-grid article {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-height: 120px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.student-report-grid strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 1.35rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.student-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.student-settings-grid.single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.student-settings-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.student-settings-grid .wide-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.student-settings-grid textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
border: 1px solid #cfd7e6;
|
||||
border-radius: 12px;
|
||||
padding: 0.8rem 1rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.program-tab {
|
||||
min-height: 72vh;
|
||||
}
|
||||
|
||||
.validation-layout {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 300px minmax(0, 1fr) 320px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.review-queue,
|
||||
.validation-inspector {
|
||||
max-height: calc(100vh - 170px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.review-queue .program-tree-list {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.review-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.review-filters span,
|
||||
.pipeline-list span {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
padding: 0.45rem 0.65rem;
|
||||
color: #475569;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.validation-stage {
|
||||
min-height: calc(100vh - 170px);
|
||||
}
|
||||
|
||||
.validation-main-preview {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
background: #f8fafc;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.validation-main-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: calc(100vh - 310px);
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-preview {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 320px;
|
||||
color: #64748b;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.validation-inspector .admin-review-form {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.program-status-grid.compact {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.pipeline-list {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
@@ -771,6 +1093,44 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.admin-console {
|
||||
height: auto;
|
||||
min-height: 100dvh;
|
||||
overflow: visible;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-console-header,
|
||||
.admin-user-box {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-workspace {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.admin-metrics-grid,
|
||||
.admin-dashboard-grid,
|
||||
.admin-students-layout,
|
||||
.student-report-grid,
|
||||
.student-settings-grid,
|
||||
.validation-layout,
|
||||
.admin-program-browser {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.review-queue,
|
||||
.validation-inspector,
|
||||
.validation-stage {
|
||||
min-height: auto;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.validation-main-preview img {
|
||||
max-height: 70dvh;
|
||||
}
|
||||
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
|
||||
Reference in New Issue
Block a user