diff --git a/README.md b/README.md
index b4e30d0..7913ca1 100644
--- a/README.md
+++ b/README.md
@@ -313,3 +313,69 @@ Exemple pour un export tous les jours à 02:00 :
```bash
0 2 * * * cd /var/www/ton-projet && npm run export:academie-excel >> /var/log/academy-export.log 2>&1
```
+
+## Export Excel des demandes projet lycée
+
+Le script `scripts/export-lycee-excel.js` génère un fichier `.xlsx` depuis la table MySQL `projet_lycee_demandes`, puis l'envoie dans Microsoft 365 via Microsoft Graph.
+
+### Migration SQL
+
+À exécuter une fois sur la base si tu veux utiliser le mode `pending` :
+
+```sql
+source server/database/2026-06-16_lycee_excel_export.sql;
+```
+
+### Variables d'environnement
+
+Les variables MySQL et Microsoft Graph communes sont les mêmes que pour l'export Académie :
+
+```env
+NUXT_MYSQL_HOST=...
+NUXT_MYSQL_PORT=3306
+NUXT_MYSQL_DATABASE=...
+NUXT_MYSQL_USER=...
+NUXT_MYSQL_PASSWORD=...
+MICROSOFT_TENANT_ID=...
+MICROSOFT_CLIENT_ID=...
+MICROSOFT_CLIENT_SECRET=...
+GRAPH_DRIVE_ID=...
+```
+
+Variables lycée à ajouter :
+
+```env
+LYCEE_GRAPH_EXCEL_UPLOAD_PATH=Projets lycee/Demandes/inscriptions-lycee-{date}.xlsx
+LYCEE_EXCEL_REPORT_EMAIL=...
+```
+
+Optionnel :
+
+```env
+LYCEE_GRAPH_EXCEL_ITEM_ID=...
+LYCEE_EXCEL_EXPORT_MODE=all
+LYCEE_EXCEL_LOCAL_OUTPUT=tmp/inscriptions-lycee-{date}.xlsx
+LYCEE_EXCEL_SKIP_UPLOAD=1
+```
+
+Modes :
+
+- `LYCEE_EXCEL_EXPORT_MODE=all` : exporte toutes les demandes dans un fichier complet, puis met à jour `exported_to_excel_at` et `export_error`.
+- `LYCEE_EXCEL_EXPORT_MODE=pending` : exporte seulement les lignes non exportées, puis met aussi `exported_to_excel` à `1`. Nécessite la migration SQL ci-dessus.
+- En cas d'échec après lecture des demandes, `export_error` est renseigné sur les lignes concernées.
+- `{date}` dans `LYCEE_GRAPH_EXCEL_UPLOAD_PATH` ou `LYCEE_EXCEL_LOCAL_OUTPUT` est remplacé par la date du jour au format `YYYY-MM-DD`.
+- `LYCEE_EXCEL_REPORT_EMAIL` reçoit un email de compte rendu après chaque export, ou un email d'erreur si le traitement échoue.
+
+### Test local sans upload
+
+```bash
+LYCEE_EXCEL_SKIP_UPLOAD=1 LYCEE_EXCEL_LOCAL_OUTPUT=tmp/inscriptions-lycee-{date}.xlsx npm run export:lycee-excel
+```
+
+### Cron nocturne
+
+Exemple pour un export tous les jours à 02:15 :
+
+```bash
+15 2 * * * cd /var/www/ton-projet && npm run export:lycee-excel >> /var/log/lycee-export.log 2>&1
+```
diff --git a/app/pages/concerts/saison.vue b/app/pages/concerts/saison.vue
index 72aa214..c5a7bcc 100644
--- a/app/pages/concerts/saison.vue
+++ b/app/pages/concerts/saison.vue
@@ -68,7 +68,7 @@
const { items: seasonSource } = useStrapi("/api/__strapi__/concerts", {
locale: "fr-FR",
- pageSize: 200,
+ pageSize: 400,
populate: {
saison_concert: true,
},
@@ -102,6 +102,7 @@
const { concerts, refresh } = useConcerts({
locale: "fr-FR",
+ pageSize: 400,
populate: {
saison_concert: true,
genre_concert: true,
diff --git a/app/pages/index.vue b/app/pages/index.vue
index 0d95572..e1e188c 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -320,9 +320,9 @@
},
{
id: '2',
- imgSrc: '/contenus/academie_orchestre_26.jpg',
- imgAlt: "Académie d'Orchestre 2026",
- title: "Académie d'Orchestre 2026",
+ imgSrc: '/contenus/academie_web_muet.jpg',
+ imgAlt: "Académie d'Orchestre 2027",
+ title: "Académie d'Orchestre 2027",
description: ``,
url:"https://www.orchestre-ile.com/mediation/academie",
},
diff --git a/package.json b/package.json
index cce6051..bbc470e 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"build": "nuxt build",
"dev": "nuxt dev",
"export:academie-excel": "node scripts/export-academie-excel.js",
+ "export:lycee-excel": "node scripts/export-lycee-excel.js",
"graph:find-drive": "node scripts/find-sharepoint-drive.js",
"generate": "nuxt generate",
"preview": "nuxt preview",
diff --git a/public/contenus/academie_web_muet.jpg b/public/contenus/academie_web_muet.jpg
new file mode 100644
index 0000000..fb09b71
Binary files /dev/null and b/public/contenus/academie_web_muet.jpg differ
diff --git a/scripts/export-lycee-excel.js b/scripts/export-lycee-excel.js
new file mode 100644
index 0000000..66b8308
--- /dev/null
+++ b/scripts/export-lycee-excel.js
@@ -0,0 +1,472 @@
+import fs from "node:fs"
+import path from "node:path"
+import process from "node:process"
+import { PassThrough } from "node:stream"
+import archiver from "archiver"
+import mysql from "mysql2/promise"
+import nodemailer from "nodemailer"
+
+const ROOT_DIR = process.cwd()
+const DEFAULT_OUTPUT_PATH = "exports/inscriptions-lycee-{date}.xlsx"
+
+const columns = [
+ ["id", "ID demande"],
+ ["created_at", "Date demande"],
+ ["nom_etablissement", "Nom établissement"],
+ ["departement", "Département"],
+ ["ville", "Ville"],
+ ["coordinateur_nom", "Coordinateur"],
+ ["email", "Email"],
+ ["telephone", "Téléphone"],
+ ["discipline", "Discipline"],
+ ["niveau_classe", "Niveau classe"],
+ ["nombre_eleves", "Nombre élèves"],
+ ["priorite_concert_rencontre", "Priorité concert-rencontre"],
+ ["priorite_immersion", "Priorité immersion"],
+ ["priorite_eac", "Priorité EAC"],
+ ["priorite_concert_commente", "Priorité concert commenté"],
+ ["periode_souhaitee", "Période souhaitée"],
+]
+
+function loadDotEnv(filePath = path.join(ROOT_DIR, ".env")) {
+ if (!fs.existsSync(filePath)) {
+ return
+ }
+
+ const content = fs.readFileSync(filePath, "utf8")
+
+ for (const line of content.split(/\r?\n/)) {
+ const trimmed = line.trim()
+
+ if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) {
+ continue
+ }
+
+ const index = trimmed.indexOf("=")
+ const key = trimmed.slice(0, index).trim()
+ let value = trimmed.slice(index + 1).trim()
+
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+ value = value.slice(1, -1)
+ }
+
+ if (!process.env[key]) {
+ process.env[key] = value
+ }
+ }
+}
+
+function requireEnv(name) {
+ const value = process.env[name]
+
+ if (!value) {
+ throw new Error(`Variable d'environnement manquante : ${name}`)
+ }
+
+ return value
+}
+
+function optionalEnv(name, fallback = "") {
+ return process.env[name] || fallback
+}
+
+function formatLocalDate(date = new Date()) {
+ const year = date.getFullYear()
+ const month = String(date.getMonth() + 1).padStart(2, "0")
+ const day = String(date.getDate()).padStart(2, "0")
+
+ return `${year}-${month}-${day}`
+}
+
+function resolveDatedPath(filePath) {
+ return filePath.replaceAll("{date}", formatLocalDate())
+}
+
+function getSmtpConfig() {
+ return {
+ host: optionalEnv("NUXT_SMTP_HOST"),
+ port: Number(optionalEnv("NUXT_SMTP_PORT", "587")),
+ secure: optionalEnv("NUXT_SMTP_SECURE", "false").toLowerCase() === "true",
+ user: optionalEnv("NUXT_SMTP_USER"),
+ password: optionalEnv("NUXT_SMTP_PASSWORD"),
+ fromEmail: optionalEnv("NUXT_SMTP_FROM_EMAIL"),
+ fromName: optionalEnv("NUXT_SMTP_FROM_NAME"),
+ reportEmail: optionalEnv("ACADEMIE_EXCEL_REPORT_EMAIL", optionalEnv("NUXT_SMTP_FROM_EMAIL")),
+ }
+}
+
+function isSmtpConfigured(config) {
+ return Boolean(config.host && config.port && config.user && config.password && config.fromEmail && config.reportEmail)
+}
+
+async function sendExportReport({ ok, mode, rowsCount = 0, localOutputPath = "", uploadPath = "", uploadedName = "", skippedUpload = false, error = null }) {
+ const config = getSmtpConfig()
+
+ if (!isSmtpConfigured(config)) {
+ console.warn("Email de rapport non envoyé : configuration SMTP incomplète.")
+ return false
+ }
+
+ const transporter = nodemailer.createTransport({
+ host: config.host,
+ port: config.port,
+ secure: config.secure,
+ connectionTimeout: 10000,
+ greetingTimeout: 10000,
+ socketTimeout: 15000,
+ auth: {
+ user: config.user,
+ pass: config.password,
+ },
+ })
+ const from = config.fromName ? `"${config.fromName}" <${config.fromEmail}>` : config.fromEmail
+ const subject = ok
+ ? `Export Lycée OK - ${formatLocalDate()}`
+ : `Export Lycée ERREUR - ${formatLocalDate()}`
+ const text = ok
+ ? [
+ "L'export Excel des demandes projet lycée s'est terminé correctement.",
+ "",
+ `Mode : ${mode}`,
+ `Demandes exportées : ${rowsCount}`,
+ localOutputPath ? `Fichier local : ${localOutputPath}` : "Fichier local : non généré",
+ skippedUpload ? "Upload SharePoint : ignoré" : `Fichier SharePoint : ${uploadPath || uploadedName}`,
+ uploadedName && uploadedName !== uploadPath ? `Nom retourné par SharePoint : ${uploadedName}` : null,
+ ].filter(Boolean).join("\n")
+ : [
+ "L'export Excel des demandes projet lycée a échoué.",
+ "",
+ `Mode : ${mode || "non déterminé"}`,
+ `Erreur : ${error?.message || String(error)}`,
+ ].join("\n")
+
+ await transporter.sendMail({
+ from,
+ to: config.reportEmail,
+ subject,
+ text,
+ })
+
+ return true
+}
+
+async function trySendExportReport(report) {
+ try {
+ return await sendExportReport(report)
+ } catch (error) {
+ console.error("Erreur envoi email de rapport :", error)
+ return false
+ }
+}
+
+function escapeXml(value) {
+ return String(value ?? "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'")
+}
+
+function columnName(index) {
+ let name = ""
+ let number = index + 1
+
+ while (number > 0) {
+ const modulo = (number - 1) % 26
+ name = String.fromCharCode(65 + modulo) + name
+ number = Math.floor((number - modulo) / 26)
+ }
+
+ return name
+}
+
+function formatCellValue(value) {
+ if (value === null || value === undefined) {
+ return ""
+ }
+
+ if (value instanceof Date) {
+ return value.toISOString().replace("T", " ").slice(0, 19)
+ }
+
+ return String(value)
+}
+
+function buildSheetXml(rows) {
+ const allRows = [columns.map(([, label]) => label), ...rows.map((row) => columns.map(([key]) => formatCellValue(row[key])))]
+ const sheetRows = allRows
+ .map((row, rowIndex) => {
+ const rowNumber = rowIndex + 1
+ const cells = row
+ .map((value, columnIndex) => {
+ const reference = `${columnName(columnIndex)}${rowNumber}`
+ return `${escapeXml(value)}`
+ })
+ .join("")
+
+ return `${cells}
`
+ })
+ .join("")
+
+ const lastColumn = columnName(columns.length - 1)
+ const lastRow = Math.max(allRows.length, 1)
+
+ return `
+
+
+
+
+ ${columns.map((_, index) => ``).join("")}
+ ${sheetRows}
+
+`
+}
+
+async function createXlsxBuffer(rows) {
+ const archive = archiver("zip", { zlib: { level: 9 } })
+ const stream = new PassThrough()
+ const chunks = []
+
+ stream.on("data", (chunk) => chunks.push(chunk))
+
+ const completed = new Promise((resolve, reject) => {
+ stream.on("end", () => resolve(Buffer.concat(chunks)))
+ stream.on("error", reject)
+ archive.on("error", reject)
+ })
+
+ archive.pipe(stream)
+ archive.append(`
+
+
+
+
+
+
+
+
+`, { name: "[Content_Types].xml" })
+ archive.append(`
+
+
+
+
+`, { name: "_rels/.rels" })
+ archive.append(`
+
+
+`, { name: "xl/workbook.xml" })
+ archive.append(`
+
+
+
+`, { name: "xl/_rels/workbook.xml.rels" })
+ archive.append(buildSheetXml(rows), { name: "xl/worksheets/sheet1.xml" })
+ archive.append(`
+
+
+
+
+
+
+
+`, { name: "xl/styles.xml" })
+ archive.append(`
+
+ wondif_vue
+`, { name: "docProps/app.xml" })
+ archive.append(`
+
+ Demandes projet lycée
+ wondif_vue
+ wondif_vue
+ ${new Date().toISOString()}
+ ${new Date().toISOString()}
+`, { name: "docProps/core.xml" })
+
+ await archive.finalize()
+ return completed
+}
+
+async function getRows(db, mode) {
+ const where = mode === "pending" ? "WHERE exported_to_excel = 0" : ""
+ const selectColumns = columns.map(([key]) => key).join(", ")
+ const [rows] = await db.execute(`
+ SELECT ${selectColumns}
+ FROM projet_lycee_demandes
+ ${where}
+ ORDER BY created_at ASC, id ASC
+ `)
+
+ return rows
+}
+
+async function getAccessToken() {
+ const tenantId = requireEnv("MICROSOFT_TENANT_ID")
+ const clientId = requireEnv("MICROSOFT_CLIENT_ID")
+ const clientSecret = requireEnv("MICROSOFT_CLIENT_SECRET")
+ const body = new URLSearchParams({
+ client_id: clientId,
+ client_secret: clientSecret,
+ scope: "https://graph.microsoft.com/.default",
+ grant_type: "client_credentials",
+ })
+ const response = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body,
+ })
+
+ if (!response.ok) {
+ throw new Error(`Erreur token Microsoft Graph (${response.status}) : ${await response.text()}`)
+ }
+
+ const data = await response.json()
+ return data.access_token
+}
+
+async function uploadToMicrosoft365(buffer) {
+ const driveId = requireEnv("GRAPH_DRIVE_ID")
+ const itemId = optionalEnv("LYCEE_GRAPH_EXCEL_ITEM_ID")
+ const uploadPath = resolveDatedPath(optionalEnv("LYCEE_GRAPH_EXCEL_UPLOAD_PATH", DEFAULT_OUTPUT_PATH)).replace(/^\/+/, "")
+ const token = await getAccessToken()
+ const url = itemId
+ ? `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(itemId)}/content`
+ : `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/root:/${uploadPath.split("/").map(encodeURIComponent).join("/")}:/content`
+ const response = await fetch(url, {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ },
+ body: buffer,
+ })
+
+ if (!response.ok) {
+ throw new Error(`Erreur upload Microsoft Graph (${response.status}) : ${await response.text()}`)
+ }
+
+ const uploaded = await response.json()
+
+ return { uploaded, uploadPath }
+}
+
+async function markRowsExportSucceeded(db, rows, { markAsExported = false } = {}) {
+ if (!rows.length) {
+ return
+ }
+
+ const exportedColumnUpdate = markAsExported ? "exported_to_excel = 1," : ""
+
+ await db.query(
+ `
+ UPDATE projet_lycee_demandes
+ SET ${exportedColumnUpdate}
+ exported_to_excel_at = NOW(),
+ export_error = NULL
+ WHERE id IN (?)
+ `,
+ [rows.map((row) => row.id)]
+ )
+}
+
+async function markRowsExportFailed(db, rows, error) {
+ if (!rows.length) {
+ return
+ }
+
+ await db.query(
+ `
+ UPDATE projet_lycee_demandes
+ SET exported_to_excel_at = NOW(),
+ export_error = ?
+ WHERE id IN (?)
+ `,
+ [String(error?.message || error).slice(0, 5000), rows.map((row) => row.id)]
+ )
+}
+
+async function main() {
+ loadDotEnv()
+
+ const mode = optionalEnv("LYCEE_EXCEL_EXPORT_MODE", "all")
+
+ if (!["all", "pending"].includes(mode)) {
+ throw new Error("LYCEE_EXCEL_EXPORT_MODE doit valoir 'all' ou 'pending'.")
+ }
+
+ const db = await mysql.createConnection({
+ host: requireEnv("NUXT_MYSQL_HOST"),
+ port: Number(optionalEnv("NUXT_MYSQL_PORT", "3306")),
+ database: requireEnv("NUXT_MYSQL_DATABASE"),
+ user: requireEnv("NUXT_MYSQL_USER"),
+ password: requireEnv("NUXT_MYSQL_PASSWORD"),
+ charset: "utf8mb4",
+ })
+
+ let rows = []
+
+ try {
+ rows = await getRows(db, mode)
+ const buffer = await createXlsxBuffer(rows)
+ const localOutputPath = resolveDatedPath(optionalEnv("LYCEE_EXCEL_LOCAL_OUTPUT", ""))
+ let absoluteOutputPath = ""
+
+ if (localOutputPath) {
+ absoluteOutputPath = path.resolve(ROOT_DIR, localOutputPath)
+ fs.mkdirSync(path.dirname(absoluteOutputPath), { recursive: true })
+ fs.writeFileSync(absoluteOutputPath, buffer)
+ console.log(`Fichier local généré : ${absoluteOutputPath}`)
+ }
+
+ if (optionalEnv("LYCEE_EXCEL_SKIP_UPLOAD", "") === "1") {
+ console.log(`Upload Microsoft 365 ignoré. ${rows.length} ligne(s) générée(s).`)
+ await trySendExportReport({
+ ok: true,
+ mode,
+ rowsCount: rows.length,
+ localOutputPath: absoluteOutputPath,
+ skippedUpload: true,
+ })
+ return
+ }
+
+ const { uploaded, uploadPath } = await uploadToMicrosoft365(buffer)
+
+ await markRowsExportSucceeded(db, rows, { markAsExported: mode === "pending" })
+
+ await trySendExportReport({
+ ok: true,
+ mode,
+ rowsCount: rows.length,
+ localOutputPath: absoluteOutputPath,
+ uploadPath,
+ uploadedName: uploaded.name || uploaded.id,
+ })
+
+ console.log(`${rows.length} demande(s) exportée(s) vers Microsoft 365 : ${uploaded.name || uploaded.id}`)
+ } catch (error) {
+ await markRowsExportFailed(db, rows, error)
+ throw error
+ } finally {
+ await db.end()
+ }
+}
+
+main().catch(async (error) => {
+ console.error(error)
+
+ try {
+ loadDotEnv()
+ await sendExportReport({
+ ok: false,
+ mode: optionalEnv("LYCEE_EXCEL_EXPORT_MODE", "all"),
+ error,
+ })
+ } catch (mailError) {
+ console.error("Erreur envoi email de rapport :", mailError)
+ }
+
+ process.exit(1)
+})
diff --git a/server/database/2026-06-16_lycee_excel_export.sql b/server/database/2026-06-16_lycee_excel_export.sql
new file mode 100644
index 0000000..ca67155
--- /dev/null
+++ b/server/database/2026-06-16_lycee_excel_export.sql
@@ -0,0 +1,5 @@
+ALTER TABLE projet_lycee_demandes
+ ADD COLUMN exported_to_excel TINYINT(1) NOT NULL DEFAULT 0,
+ ADD COLUMN exported_to_excel_at DATETIME NULL,
+ ADD COLUMN export_error TEXT NULL,
+ ADD INDEX idx_projet_lycee_exported_to_excel (exported_to_excel, created_at);
diff --git a/server/utils/mailer.js b/server/utils/mailer.js
index 8a75588..bc6e7a3 100644
--- a/server/utils/mailer.js
+++ b/server/utils/mailer.js
@@ -357,7 +357,6 @@ export async function sendProjetAcademieEmails(payload) {
mailer.sendMail({
from,
to: config.academieRequestRecipientEmail,
- replyTo: payload.email,
subject: adminSubject,
text: adminText,
}),
diff --git a/tmp/inscriptions-lycee-2026-06-16.xlsx b/tmp/inscriptions-lycee-2026-06-16.xlsx
new file mode 100644
index 0000000..4abbd8d
Binary files /dev/null and b/tmp/inscriptions-lycee-2026-06-16.xlsx differ
diff --git a/tmp/inscriptions-lycee-2026-06-17.xlsx b/tmp/inscriptions-lycee-2026-06-17.xlsx
new file mode 100644
index 0000000..5a258eb
Binary files /dev/null and b/tmp/inscriptions-lycee-2026-06-17.xlsx differ