Cockpit export

This commit is contained in:
2026-05-07 21:24:18 +02:00
parent b41993c455
commit dd3f849416
2 changed files with 234 additions and 26 deletions

View File

@@ -8,6 +8,20 @@ const SILENCE_THRESHOLD = 0.012
const DEBUG_AUDIO = false
const TTS_PROFILE_STORAGE_KEY = 'professeur-top-tts-profile'
const REVIEW_STATUS_OPTIONS = [
{ value: 'needs_changes', label: 'A corriger' },
{ value: 'to_review', label: 'A valider' },
{ value: 'treated', label: 'Traite' },
{ value: 'archived', label: 'Archive' },
{ value: 'validated', label: 'Valide' },
{ value: 'blocked', label: 'Bloque' },
{ value: 'draft', label: 'Brouillon' },
]
const REVIEW_ACTIVE_STATUSES = new Set(['needs_changes', 'to_review', 'blocked', 'draft', 'unreviewed'])
const REVIEW_NODE_TYPES = ['lesson', 'fiche', 'card', 'kit', 'diagnostic', 'parcours', 'support']
const GRADE_SLUGS = new Set(['cp', 'ce1', 'ce2', 'cm1', 'cm2', '6e', '5e', '4e', '3e'])
async function parseApiResponse(res) {
const contentType = res.headers.get('content-type') || ''
const bodyText = await res.text()
@@ -117,6 +131,74 @@ function collectCollapsibleProgramKeys(node, depth = 0, keys = []) {
return keys
}
function reviewStatusLabel(status) {
return REVIEW_STATUS_OPTIONS.find((item) => item.value === status)?.label || 'A relire'
}
function titleFromProgramSlug(value) {
if (!value) return ''
return value
.replace(/^cycle_/, 'Cycle ')
.replace(/_/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())
}
function getProgramNodeReviewMeta(node) {
const rawPath = node?.path || node?.key || ''
const parts = rawPath.split(/[\/#]/).filter(Boolean)
const cycle = parts.find((part) => part.startsWith('cycle_')) || ''
const grade = parts.find((part) => GRADE_SLUGS.has(part.toLowerCase())) || ''
const subject = parts.find((part) => ['mathematiques', 'francais', 'langues', 'emc'].includes(part.toLowerCase())) || ''
const status = node?.review?.status || 'unreviewed'
return {
cycle,
cycleLabel: titleFromProgramSlug(cycle),
grade: grade.toUpperCase(),
subject,
subjectLabel: titleFromProgramSlug(subject),
status,
statusLabel: reviewStatusLabel(status),
type: node?.type || '',
}
}
function exportReviewRows(rows, filters) {
const exportedAt = new Date()
const payload = {
schema: 'professeur-top-content-reviews-v1',
exported_at: exportedAt.toISOString(),
filters,
count: rows.length,
reviews: rows.map(({ node, meta }) => ({
node_key: node.key,
path: node.path || node.key,
label: node.label,
type: node.type,
cycle: meta.cycle,
grade: meta.grade,
subject: meta.subject,
status: meta.status,
status_label: meta.statusLabel,
comment: node.review?.comment || '',
reviewer: node.review?.reviewer || '',
updated_at: node.review?.updated_at || '',
has_visual: Boolean(node.url),
has_text_preview: Boolean(node.content),
})),
}
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
const datePart = exportedAt.toISOString().slice(0, 10)
anchor.href = url
anchor.download = `revues-contenus-${datePart}.json`
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
}
function ProgramReviewPanel({ selectedNode, reviewForm, onReviewChange, onSaveReview, readOnly = false, onOpenValidation }) {
const [isFullscreen, setIsFullscreen] = useState(false)
@@ -191,10 +273,9 @@ function ProgramReviewPanel({ selectedNode, reviewForm, onReviewChange, onSaveRe
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>
{REVIEW_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label>
@@ -245,10 +326,9 @@ function ProgramReviewPanel({ selectedNode, reviewForm, onReviewChange, onSaveRe
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>
{REVIEW_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label>
@@ -389,6 +469,14 @@ function TutorApp({ currentUser, onLogout }) {
const [programTree, setProgramTree] = useState(null)
const [selectedProgramNodeKey, setSelectedProgramNodeKey] = useState('')
const [programReviewForm, setProgramReviewForm] = useState({ status: 'needs_changes', comment: '' })
const [reviewFilters, setReviewFilters] = useState({
cycle: 'all',
grade: 'all',
subject: 'all',
status: 'active',
type: 'all',
query: '',
})
const [activeAdminTab, setActiveAdminTab] = useState('overview')
const [collapsedProgramKeys, setCollapsedProgramKeys] = useState(() => new Set())
const [studentProgram, setStudentProgram] = useState(null)
@@ -1856,10 +1944,33 @@ function TutorApp({ currentUser, onLogout }) {
const averageComprehension = cardProgress.length
? Math.round(cardProgress.reduce((sum, item) => sum + item.comprehension_score, 0) / cardProgress.length)
: 0
const reviewRows = programRows.filter(({ node }) =>
['lesson', 'fiche', 'card', 'kit', 'diagnostic', 'parcours', 'support'].includes(node.type)
)
const reviewQueue = reviewRows.filter(({ node }) => ['needs_changes', 'blocked', 'draft', undefined].includes(node.review?.status))
const reviewRows = programRows
.filter(({ node }) => REVIEW_NODE_TYPES.includes(node.type))
.map((row) => ({ ...row, meta: getProgramNodeReviewMeta(row.node) }))
const reviewQueue = reviewRows.filter(({ meta }) => REVIEW_ACTIVE_STATUSES.has(meta.status))
const reviewFilterOptions = {
cycles: [...new Map(reviewRows.filter(({ meta }) => meta.cycle).map(({ meta }) => [meta.cycle, meta.cycleLabel])).entries()],
grades: [...new Set(reviewRows.map(({ meta }) => meta.grade).filter(Boolean))].sort(),
subjects: [...new Map(reviewRows.filter(({ meta }) => meta.subject).map(({ meta }) => [meta.subject, meta.subjectLabel])).entries()],
statuses: REVIEW_STATUS_OPTIONS.filter((option) => reviewRows.some(({ meta }) => meta.status === option.value)),
types: [...new Set(reviewRows.map(({ node }) => node.type).filter(Boolean))].sort(),
}
const filteredReviewRows = reviewRows.filter(({ node, meta }) => {
const query = reviewFilters.query.trim().toLowerCase()
const matchesQuery = !query || `${node.label} ${node.path || node.key} ${node.review?.comment || ''}`.toLowerCase().includes(query)
const matchesStatus = reviewFilters.status === 'all'
|| (reviewFilters.status === 'active' && REVIEW_ACTIVE_STATUSES.has(meta.status))
|| meta.status === reviewFilters.status
return (
(reviewFilters.cycle === 'all' || meta.cycle === reviewFilters.cycle)
&& (reviewFilters.grade === 'all' || meta.grade === reviewFilters.grade)
&& (reviewFilters.subject === 'all' || meta.subject === reviewFilters.subject)
&& matchesStatus
&& (reviewFilters.type === 'all' || node.type === reviewFilters.type)
&& matchesQuery
)
})
const exportableReviewRows = filteredReviewRows.filter(({ node, meta }) => meta.status !== 'unreviewed' || Boolean(node.review?.comment))
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)
@@ -2269,20 +2380,80 @@ function TutorApp({ currentUser, onLogout }) {
<div>
<span className="stage-label">File éditoriale</span>
<h2>Contenus à valider</h2>
<p className="muted">{filteredReviewRows.length} élément(s) affiché(s) sur {reviewRows.length}</p>
</div>
<button
type="button"
className="secondary-button"
onClick={() => exportReviewRows(exportableReviewRows, reviewFilters)}
disabled={!exportableReviewRows.length}
>
Exporter JSON ({exportableReviewRows.length})
</button>
</div>
<div className="review-filters">
<span>À relire</span>
<span>À modifier</span>
<span>Validé</span>
<span>Intégré</span>
<label>
<span>Cycle</span>
<select value={reviewFilters.cycle} onChange={(event) => setReviewFilters({ ...reviewFilters, cycle: event.target.value })}>
<option value="all">Tous</option>
{reviewFilterOptions.cycles.map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
<label>
<span>Classe</span>
<select value={reviewFilters.grade} onChange={(event) => setReviewFilters({ ...reviewFilters, grade: event.target.value })}>
<option value="all">Toutes</option>
{reviewFilterOptions.grades.map((grade) => (
<option key={grade} value={grade}>{grade}</option>
))}
</select>
</label>
<label>
<span>Matière</span>
<select value={reviewFilters.subject} onChange={(event) => setReviewFilters({ ...reviewFilters, subject: event.target.value })}>
<option value="all">Toutes</option>
{reviewFilterOptions.subjects.map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
<label>
<span>Statut</span>
<select value={reviewFilters.status} onChange={(event) => setReviewFilters({ ...reviewFilters, status: event.target.value })}>
<option value="active">Actifs</option>
<option value="all">Tous</option>
<option value="unreviewed">A relire</option>
{REVIEW_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label>
<span>Type</span>
<select value={reviewFilters.type} onChange={(event) => setReviewFilters({ ...reviewFilters, type: event.target.value })}>
<option value="all">Tous</option>
{reviewFilterOptions.types.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
</label>
<label className="review-search">
<span>Recherche</span>
<input
value={reviewFilters.query}
onChange={(event) => setReviewFilters({ ...reviewFilters, query: event.target.value })}
placeholder="Titre, chemin, commentaire"
/>
</label>
</div>
<div className="program-tree-list">
{reviewRows.map(({ node, depth }) => (
{filteredReviewRows.map(({ node, depth, meta }) => (
<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>
<strong>{meta.statusLabel}</strong>
</button>
))}
</div>
@@ -2325,10 +2496,9 @@ function TutorApp({ currentUser, onLogout }) {
<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>
{REVIEW_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label>

View File

@@ -290,12 +290,38 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
}
.review-filters {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
.review-filters label {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.25rem;
color: #475569;
font-size: 0.78rem;
font-weight: 800;
}
.review-filters select,
.review-filters input {
width: 100%;
min-width: 0;
border: 1px solid #cfd7e6;
border-radius: 10px;
background: white;
padding: 0.45rem 0.55rem;
color: #14213d;
font: inherit;
font-size: 0.85rem;
}
.review-filters .review-search {
grid-column: 1 / -1;
}
.review-filters span,
.pipeline-list span {
border: 1px solid #e2e8f0;
border-radius: 999px;
@@ -700,6 +726,18 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
background: #ffedd5;
color: #9a3412;
}
.review-pill.to_review {
background: #dbeafe;
color: #1d4ed8;
}
.review-pill.treated {
background: #e0f2fe;
color: #0369a1;
}
.review-pill.archived {
background: #f1f5f9;
color: #64748b;
}
.review-pill.blocked {
background: #fee2e2;
color: #991b1b;