diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e35a009..3d69a3b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 })} > - Validé - À modifier - Bloqué - Brouillon + {REVIEW_STATUS_OPTIONS.map((option) => ( + {option.label} + ))} @@ -245,10 +326,9 @@ function ProgramReviewPanel({ selectedNode, reviewForm, onReviewChange, onSaveRe value={reviewForm.status} onChange={(event) => onReviewChange({ ...reviewForm, status: event.target.value })} > - Validé - À modifier - Bloqué - Brouillon + {REVIEW_STATUS_OPTIONS.map((option) => ( + {option.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 }) { File éditoriale Contenus à valider + {filteredReviewRows.length} élément(s) affiché(s) sur {reviewRows.length} + exportReviewRows(exportableReviewRows, reviewFilters)} + disabled={!exportableReviewRows.length} + > + Exporter JSON ({exportableReviewRows.length}) + - À relire - À modifier - Validé - Intégré + + Cycle + setReviewFilters({ ...reviewFilters, cycle: event.target.value })}> + Tous + {reviewFilterOptions.cycles.map(([value, label]) => ( + {label} + ))} + + + + Classe + setReviewFilters({ ...reviewFilters, grade: event.target.value })}> + Toutes + {reviewFilterOptions.grades.map((grade) => ( + {grade} + ))} + + + + Matière + setReviewFilters({ ...reviewFilters, subject: event.target.value })}> + Toutes + {reviewFilterOptions.subjects.map(([value, label]) => ( + {label} + ))} + + + + Statut + setReviewFilters({ ...reviewFilters, status: event.target.value })}> + Actifs + Tous + A relire + {REVIEW_STATUS_OPTIONS.map((option) => ( + {option.label} + ))} + + + + Type + setReviewFilters({ ...reviewFilters, type: event.target.value })}> + Tous + {reviewFilterOptions.types.map((type) => ( + {type} + ))} + + + + Recherche + setReviewFilters({ ...reviewFilters, query: event.target.value })} + placeholder="Titre, chemin, commentaire" + /> + - {reviewRows.map(({ node, depth }) => ( + {filteredReviewRows.map(({ node, depth, meta }) => ( setSelectedProgramNodeKey(node.key)}> {node.label} {node.type} - {node.review?.status || 'à relire'} + {meta.statusLabel} ))} @@ -2325,10 +2496,9 @@ function TutorApp({ currentUser, onLogout }) { Statut setProgramReviewForm({ ...programReviewForm, status: event.target.value })}> - Validé - À modifier - Bloqué - Brouillon + {REVIEW_STATUS_OPTIONS.map((option) => ( + {option.label} + ))} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index bff0c8c..1bb090a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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;
{filteredReviewRows.length} élément(s) affiché(s) sur {reviewRows.length}