Cockpit export
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user