/* Admin flow v2 — login + dashboard + respondents list + detail + invitations */
const { useState: useStateB, useMemo: useMemoB } = React;
const PALETTE = [
"var(--byi-navy-800)",
"var(--byi-orange-500)",
"var(--byi-cyan-500)",
"var(--byi-teal-500)",
"var(--byi-coral-500)",
"var(--byi-yellow-500)",
"var(--byi-purple-500)",
"var(--byi-navy-600)"
];
/* ============================================================
Aggregation specific to v2 schema
============================================================ */
function aggV2(respondents) {
const count = (arr, key) => {
const m = new Map();
arr.forEach(r => {
const v = r[key];
if (Array.isArray(v)) v.forEach(x => m.set(x, (m.get(x) || 0) + 1));
else if (v != null && v !== "") m.set(v, (m.get(v) || 0) + 1);
});
return [...m.entries()].map(([label, value]) => ({ label, value })).sort((a, b) => b.value - a.value);
};
const users = respondents.filter(r => r.isUser);
const skillHist = Array.from({ length: 10 }, (_, i) => ({
label: String(i + 1),
value: respondents.filter(r => r.skill === i + 1).length
}));
const avgSkill = respondents.reduce((s, r) => s + r.skill, 0) / Math.max(respondents.length, 1);
const dayCounts = Array.from({ length: 21 }, () => 0);
respondents.forEach(r => {
const days = Math.floor((new Date(2026, 4, 23) - r.submittedAt) / (1000 * 60 * 60 * 24));
const idx = 20 - Math.max(0, Math.min(20, days));
dayCounts[idx] += 1;
});
// Themes — simple multi-select count
const themesWeighted = (() => {
const m = new Map();
respondents.forEach(r => (r.themes || []).forEach(t => m.set(t, (m.get(t) || 0) + 1)));
return [...m.entries()].map(([label, value]) => ({ label, value })).sort((a, b) => b.value - a.value);
})();
// Headcount per service totals + modules x service matrix
const services = new Map();
const moduleServiceMatrix = new Map(); // service -> Map(module -> count)
respondents.forEach(r => {
if (!r.trainingPlan) return;
Object.entries(r.trainingPlan).forEach(([s, plan]) => {
services.set(s, (services.get(s) || 0) + (Number(plan.headcount) || 0));
if (!moduleServiceMatrix.has(s)) moduleServiceMatrix.set(s, new Map());
(plan.themes || []).forEach(m => {
const ms = moduleServiceMatrix.get(s);
ms.set(m, (ms.get(m) || 0) + 1);
});
});
});
const headcountByService = [...services.entries()]
.map(([label, value]) => ({ label, value }))
.sort((a, b) => b.value - a.value);
const modulesByService = [...moduleServiceMatrix.entries()]
.map(([service, ms]) => ({
service,
modules: [...ms.entries()].map(([label, value]) => ({ label, value })).sort((a, b) => b.value - a.value)
}));
// People-to-train per module: sum headcount of every (respondent × service) where the module is selected
const moduleTrainees = (() => {
const m = new Map();
respondents.forEach(r => {
if (!r.trainingPlan) return;
Object.values(r.trainingPlan).forEach(plan => {
const h = Number(plan.headcount) || 0;
if (h <= 0) return;
(plan.themes || []).forEach(t => m.set(t, (m.get(t) || 0) + h));
});
});
return [...m.entries()].map(([label, value]) => ({ label, value })).sort((a, b) => b.value - a.value);
})();
// Tool counts grouped by category
const toolsByGroup = window.FESPA_DATA.catalog.TOOLS_GROUPS.map(g => ({
group: g.group,
items: g.tools.map(t => ({
label: t,
value: users.filter(r => r.tools.includes(t)).length
})).sort((a, b) => b.value - a.value)
}));
// Licences: number of orgs with each tool + total licences
const licCount = new Map();
const licSeats = new Map();
respondents.forEach(r => {
(r.licences || []).forEach(l => {
licCount.set(l.tool, (licCount.get(l.tool) || 0) + 1);
licSeats.set(l.tool, (licSeats.get(l.tool) || 0) + l.count);
});
});
const licencesData = [...licCount.entries()].map(([tool, orgs]) => ({
label: tool, orgs, seats: licSeats.get(tool) || 0
})).sort((a, b) => b.seats - a.seats);
return {
roles: count(respondents, "role"),
sizes: count(respondents, "sizeLabel"),
sectors: count(respondents, "sectors"),
opcoSalaries: count(respondents, "opcoSalaries"),
opcoDirigeant: count(respondents, "opcoDirigeant"),
usage: count(respondents, "usage"),
domains: count(users, "domains"),
benefits: count(users, "benefits"),
policy: count(respondents, "policy"),
barriers: count(respondents, "barriers"),
paidStatus: count(respondents.filter(r => r.isUser), "paidStatus"),
wantsFormation: count(respondents, "wantsFormation"),
budget: count(respondents.filter(r => r.wantsFormation !== "Non, pas pour l'instant"), "budgetLabel"),
decider: count(respondents.filter(r => r.wantsFormation !== "Non, pas pour l'instant"), "decider"),
pastTrained: respondents.filter(r => r.pastTraining).length,
skillHist, avgSkill, dayCounts,
themesWeighted,
headcountByService,
modulesByService,
moduleTrainees,
toolsByGroup,
licencesData
};
}
/* ============================================================
CSV export
============================================================ */
function exportRespondentsCSV(respondents, catalog) {
const headers = [
"ID", "Soumis le", "Prénom", "Nom", "Email", "Entreprise", "Rôle",
"Effectif (tranche)", "Secteurs", "OPCO salariés", "OPCO dirigeant",
"Niveau IA (/10)", "Usage IA",
"Outils utilisés", "Statut licences payantes", "Outils sous licence (sièges)",
"Domaines d'usage", "Bénéfices observés",
"Politique IA", "Freins identifiés",
"Formation IA déjà suivie", "Détails formation antérieure",
"Intérêt formation", "Plan par service (apprenants × modules)",
"Total apprenants", "Budget / personne", "Décideur formation",
"Atelier témoignage", "Commentaire libre"
];
const escape = (v) => {
if (v == null) return "";
const s = String(v);
if (s.includes('"') || s.includes(";") || s.includes("\n") || s.includes(",")) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
};
const fmtDate = (d) => {
const dt = d instanceof Date ? d : new Date(d);
return `${String(dt.getDate()).padStart(2,"0")}/${String(dt.getMonth()+1).padStart(2,"0")}/${dt.getFullYear()} ${String(dt.getHours()).padStart(2,"0")}:${String(dt.getMinutes()).padStart(2,"0")}`;
};
const rows = respondents.map(r => {
const licStr = (r.licences || []).map(l => `${l.tool} (${l.count})`).join(" | ");
const planStr = r.trainingPlan
? Object.entries(r.trainingPlan)
.filter(([, p]) => Number(p.headcount) > 0 || (p.themes||[]).length > 0)
.map(([s, p]) => `${s}: ${p.headcount} apprenant(s)${p.themes && p.themes.length ? " — " + p.themes.join(", ") : ""}`)
.join(" | ")
: "";
const pastStr = r.pastTraining
? `${r.pastTraining.title} (${r.pastTraining.organism}, ${r.pastTraining.year}, ${r.pastTraining.headcount} pers.)`
: "";
return [
r.id,
fmtDate(r.submittedAt),
r.firstName,
r.lastName,
r.email,
r.company,
r.role,
r.sizeLabel,
(r.sectors || []).join(" | "),
(r.opcoSalaries || []).join(" | "),
(r.opcoDirigeant || []).join(" | "),
r.skill,
r.usage,
(r.tools || []).join(" | "),
r.paidStatus || "",
licStr,
(r.domains || []).join(" | "),
(r.benefits || []).join(" | "),
r.policy || "",
(r.barriers || []).join(" | "),
r.pastTraining ? "Oui" : "Non",
pastStr,
r.wantsFormation || "",
planStr,
r.trainTotal || 0,
r.budgetLabel || "",
r.decider || "",
r.willingToShare || "",
r.comment || ""
];
});
const sep = ";"; // semicolon: Excel FR friendly
const lines = [headers.join(sep), ...rows.map(r => r.map(escape).join(sep))];
const csv = "\uFEFF" + lines.join("\r\n"); // BOM for Excel UTF-8
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const today = new Date();
const stamp = `${today.getFullYear()}${String(today.getMonth()+1).padStart(2,"0")}${String(today.getDate()).padStart(2,"0")}`;
a.href = url;
a.download = `FESPA-enquete-IA-${stamp}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
window.exportRespondentsCSV = exportRespondentsCSV;
/* ============================================================
Login
============================================================ */
function AdminLogin({ onLogin }) {
const [email, setEmail] = useStateB("admin@fespa-france.fr");
const [pw, setPw] = useStateB("••••••••");
const [showHint, setShowHint] = useStateB(false);
return (
Espace administrateur · accès sécurisé
Suivi de l'enquête Usage de l'IA — 2026
Commission Intelligence Artificielle
Une lecture en temps réel du pouls de la filière.
Volume de réponses & couverture OPCO
Outils utilisés et licences payantes en place
Intentions de formation par service et par budget
Export CSV pour vos comités
);
}
/* ============================================================
Campaigns — Several surveys per year, each with its own questionnaire
============================================================ */
const FESPA_CAMPAIGNS = [
{
id: "ia-2026",
name: "Usage de l'IA — Édition 2026",
period: "Mai → Juin 2026",
startDate: "2026-05-05",
endDate: "2026-06-12",
status: "open",
invited: 416,
responded: 80,
questionnaireId: "ia-v2",
questionsCount: 20,
blocks: 5,
description: "Cartographier l'adoption de l'IA et identifier les besoins en formation des adhérents.",
isPrimary: true
},
{
id: "conjoncture-q1-2026",
name: "Conjoncture filière — T1 2026",
period: "Mars → Avril 2026",
startDate: "2026-03-01",
endDate: "2026-04-15",
status: "closed",
invited: 416,
responded: 248,
questionnaireId: "conjoncture",
questionsCount: 12,
blocks: 3,
description: "Baromètre trimestriel : carnet de commandes, prix matières, recrutement."
},
{
id: "salon-2025",
name: "Bilan Salon FESPA Global 2025",
period: "Octobre 2025",
startDate: "2025-10-15",
endDate: "2025-11-15",
status: "closed",
invited: 198,
responded: 142,
questionnaireId: "salon-bilan",
questionsCount: 15,
blocks: 4,
description: "Bilan post-salon — satisfaction exposants, contacts générés, intentions 2026."
},
{
id: "formation-2027",
name: "Plan de formation 2027 — Exploration",
period: "Septembre → Octobre 2026 (prévue)",
startDate: "2026-09-01",
endDate: "2026-10-31",
status: "draft",
invited: 0,
responded: 0,
questionnaireId: "formation-2027",
questionsCount: 8,
blocks: 2,
description: "Questionnaire en cours de rédaction — orientations pour le plan 2027."
}
];
window.FESPA_CAMPAIGNS = FESPA_CAMPAIGNS;
const ADMIN_AREAS = [
{ id: "survey", label: "Suivi de l'enquête", icon: "📊",
tabs: [
["campaigns", "Campagnes"],
["dashboard", "Vue d'ensemble"],
["respondents", "Répondants"],
["invitations", "Invitations"]
] },
{ id: "training", label: "Inscriptions aux formations", icon: "🎓",
tabs: [
["sessions", "Sessions"],
["enrollments", "Inscrits"],
["catalog", "Catalogue"]
] },
{ id: "planning", label: "Rétro-planning", icon: "🗓",
tabs: [
["gantt", "Vue planning"],
["milestones", "Jalons clés"]
] }
];
function AdminShell({ area, tab, onArea, onTab, onLogout, campaignId, onCampaignChange, children }) {
const current = ADMIN_AREAS.find(a => a.id === area) || ADMIN_AREAS[0];
const currentCampaign = FESPA_CAMPAIGNS.find(c => c.id === campaignId) || FESPA_CAMPAIGNS[0];
const showSwitcher = area === "survey" && tab !== "campaigns";
return (
{current.label}
{current.tabs.map(([id, label]) => (
onTab(id)}
>{label}
))}
{showSwitcher && (
Campagne :
onCampaignChange(e.target.value)}>
{FESPA_CAMPAIGNS.map(c => (
{c.name} {c.status === "open" ? "· en cours" : c.status === "closed" ? "· clôturée" : "· brouillon"}
))}
)}
{children}
);
}
function CampaignStatus({ status }) {
const map = {
open: ["var(--success)", "Ouverte"],
closed: ["var(--byi-navy-300)", "Clôturée"],
draft: ["var(--byi-orange-500)", "Brouillon"],
scheduled: ["var(--byi-cyan-500)", "Programmée"]
};
const [c, l] = map[status] || map.draft;
return {l} ;
}
function AdminCampaigns({ onSelectCampaign }) {
const [showCreate, setShowCreate] = useStateB(false);
return (
Toutes les campagnes
{FESPA_CAMPAIGNS.length} enquêtes — édition annuelle
Exporter le bilan
setShowCreate(true)}>Nouvelle campagne
{FESPA_CAMPAIGNS.map(c => {
const rate = c.invited > 0 ? Math.round(c.responded / c.invited * 100) : 0;
return (
{c.description}
Questions
{c.questionsCount}
Blocs
{c.blocks}
Invitations
{c.invited > 0 ? c.invited : "—"}
Réponses
{c.responded > 0 ? c.responded : "—"}
{c.invited > 0 && (
)}
{c.status === "draft" ? (
✎ Éditer le questionnaire
) : (
onSelectCampaign(c.id)}>
📊 Voir les résultats →
)}
⋯
);
})}
{/* Create campaign modal */}
{showCreate && (
setShowCreate(false)}>
e.stopPropagation()}>
Nouvelle campagne
Créer une nouvelle enquête
setShowCreate(false)}>×
Une campagne correspond à une enquête diffusée auprès des adhérents.
Chaque campagne a son propre questionnaire, sa propre liste d'invités et son propre tableau de résultats.
setShowCreate(false)}>Annuler
+ Créer la campagne
)}
);
}
function CampaignClosedNotice({ campaign }) {
return (
📋
{campaign.period}
{campaign.name}
{campaign.description}
Statut
Questions {campaign.questionsCount}
Invitations {campaign.invited}
Réponses {campaign.responded} ({Math.round(campaign.responded/Math.max(campaign.invited,1)*100)}%)
{campaign.status === "closed"
? "Cette campagne est clôturée. Les données restent consultables et exportables. Cliquez sur les onglets « Répondants » et « Invitations » pour explorer les détails."
: campaign.status === "draft"
? "Cette campagne est en cours de préparation — le questionnaire n'a pas encore été diffusé aux adhérents."
: "Campagne en cours."}
);
}
/* ============================================================
Dashboard
============================================================ */
function AdminDashboard({ data, onOpenRespondent }) {
const totals = data.totals;
const agg = useMemoB(() => aggV2(data.respondents), [data.respondents]);
const users = data.respondents.filter(r => r.isUser);
const wantsFormation = data.respondents.filter(r => r.wantsFormation !== "Non, pas pour l'instant");
const satOrder = [
["Oui, une charte ou politique formalisée", "var(--success)"],
["Oui, des règles informelles", "var(--byi-teal-500)"],
["Non, mais c'est en réflexion", "var(--byi-orange-500)"],
["Non, pas du tout", "var(--byi-coral-500)"]
];
return (
Vue d'ensemble · 23 mai 2026
Enquête « Usage de l'IA » — résultats en direct
● Enquête ouverte
exportRespondentsCSV(data.respondents, data.catalog)}>Exporter CSV
Relancer {totals.invited - totals.responded} non-répondants
{/* ===== Row 1 — Top KPIs ===== */}
Taux de réponse
+9 cette semaine
{totals.responded} questionnaires saisis sur {totals.invited} adhérents invités.
Objectif comité : 60 %. Estimé atteint sous 8 jours.
Volume collaborateurs cumulé
{totals.collaborators.toLocaleString("fr-FR")}
salariés représentés au total
{agg.sizes.map((s, i) => (
{s.label}
{s.value}
))}
Niveau IA moyen
{agg.avgSkill.toFixed(1)}/10
déclaré par les répondants
({ ...d, highlight: Number(d.label) === Math.round(agg.avgSkill) }))}
height={120}
color="var(--byi-navy-100)"
accent="var(--byi-orange-500)"
/>
Courbe des réponses · 21 jours
+{agg.dayCounts.slice(-7).reduce((s, v) => s + v, 0)}
réponses sur les 7 derniers jours
il y a 21 j aujourd'hui
{/* ===== Row 2 — Profil & OPCO ===== */}
Profil — rôle des répondants
({ ...r, color: PALETTE[i % PALETTE.length] }))}
height={22}
/>
Secteur d'activité
({ ...s, color: PALETTE[i % PALETTE.length] }))}
height={22}
/>
OPCO de rattachement
Couverture par OPCO
Pour les salariés
({
...o, color: i === 0 ? "var(--byi-orange-500)" : "var(--byi-navy-300)"
}))}
height={18}
/>
Pour le dirigeant
({
...o, color: i === 0 ? "var(--byi-orange-500)" : "var(--byi-navy-300)"
}))}
height={18}
/>
{/* ===== Row 3 — Adoption + Tools by category ===== */}
Adoption de l'IA dans la filière
Où en sont les entreprises adhérentes ?
({
...u,
color: ["Oui, régulièrement","Oui, occasionnellement","Nous sommes en phase de test"].includes(u.label)
? "var(--byi-orange-500)"
: "var(--byi-navy-300)"
}))}
height={26}
/>
{Math.round(100 * users.length / data.respondents.length)}%
utilisent déjà l'IA
{Math.round(100 * (agg.usage.find(u => u.label === "Nous sommes en phase de test") || {value:0}).value / data.respondents.length)}%
en phase de test
{Math.round(100 * (agg.usage.find(u => u.label === "Non, mais nous y réfléchissons") || {value:0}).value / data.respondents.length)}%
en réflexion
Outils utilisés · par catégorie
Top 3 par famille d'outils
{users.length} utilisateurs IA
{agg.toolsByGroup.map(g => (
{g.group}
i.value > 0).map((t, i) => ({
...t, color: i === 0 ? "var(--byi-navy-800)" : "var(--byi-navy-300)"
}))}
height={16}
/>
))}
{/* ===== Row 4 — Licences payantes ===== */}
Licences payantes en place
Quels outils sont déjà budgétés dans la filière ?
{data.respondents.filter(r=>(r.licences||[]).length>0).length} entreprises avec licences
Statut licences (parmi utilisateurs IA)
({
...p,
color: p.label.startsWith("Oui") ? "var(--success)"
: p.label === "En cours de réflexion" ? "var(--byi-orange-500)" : "var(--byi-navy-300)"
}))}
height={20}
/>
Top outils souscrits (sièges cumulés)
Outil Entreprises Sièges totaux
{agg.licencesData.slice(0, 8).map(l => (
{l.label}
{l.orgs}
{l.seats}
))}
{/* ===== Row 5 — Intentions de formation (LE bloc clé) ===== */}
Intentions de formation · programmes
Quels sujets de formation IA intéressent les adhérents FESPA France
{totals.peopleToTrain.toLocaleString("fr-FR")}
personnes à former~{Math.round(totals.trainingRevenue/1000).toLocaleString("fr-FR")} k€ potentiels
{/* Funnel of intent */}
{agg.wantsFormation.map((w, i) => {
const pct = Math.round(w.value / data.respondents.length * 100);
const isPos = w.label.startsWith("Oui");
return (
{pct}%
{w.label}
{w.value} adhérents
);
})}
Thèmes plébiscités (nb de répondants)
({
...t,
color: i < 3 ? "var(--byi-orange-500)" : "var(--byi-orange-300)"
}))}
height={22}
/>
Apprenants à former · par module
({
...t,
color: i < 3 ? "var(--accent)" : "var(--byi-orange-300)"
}))}
height={22}
/>
Budget par personne
{agg.budget.map((b) => (
{b.label}
x.value))) * 100}%`,
background: b.label.includes("Je ne sais pas") ? "var(--byi-navy-300)" : "var(--byi-orange-500)"
}}>
{b.value}
))}
Personnes à former · par service
({
...t, color: PALETTE[i % PALETTE.length]
}))}
height={18}
/>
Modules les plus demandés · par service
{agg.modulesByService
.filter(ms => ms.modules.length > 0)
.slice(0, 5)
.map(ms => (
{ms.service}
{ms.modules.slice(0, 3).map((m, i) => (
{m.value}
{m.label}
))}
))}
Décideur formation
{agg.decider.map((t) => (
{t.value}
{t.label}
))}
{wantsFormation.length}
adhérents intéressés
{totals.peopleToTrain.toLocaleString("fr-FR")}
collaborateurs à former
{/* ===== Row 6 — Gouvernance, freins, déjà formés ===== */}
Politique d'usage IA en interne
{satOrder.map(([label, color]) => {
const v = (agg.policy.find(p => p.label === label) || {value:0}).value;
const pct = Math.round(v / data.respondents.length * 100);
return (
{label.replace("Oui, une charte ou politique formalisée","Charte formalisée").replace("Oui, des règles informelles","Règles informelles").replace("Non, mais c'est en réflexion","En réflexion").replace("Non, pas du tout","Aucune")}
);
})}
Principaux freins identifiés
({
...b, color: i < 2 ? "var(--byi-coral-500)" : "var(--byi-navy-300)"
}))}
height={18}
/>
Maturité formation
{agg.pastTrained} adhérents ont déjà suivi une formation IA.
{data.respondents.length - agg.pastTrained} restent à former — cible prioritaire de la Commission.
Domaines d'usage
({
...d, color: PALETTE[i % PALETTE.length]
}))}
height={14}
/>
{/* ===== Row 7 — Latest respondents ===== */}
Derniers questionnaires reçus
10 réponses les plus récentes
Voir tous les répondants →
Reçu le Entreprise Secteur Effectif
Usage Niveau Pers. à former
{[...data.respondents].sort((a,b)=>b.submittedAt-a.submittedAt).slice(0, 10).map(r => (
onOpenRespondent(r.id)}>
{formatDate(r.submittedAt)}
{r.company}
{r.sectors.join(", ")}
{r.sizeLabel.replace(" salariés","")}
{r.skill}/10
{r.trainTotal > 0 ? {r.trainTotal} : — }
→
))}
);
}
function UsageDot({ usage }) {
const map = {
"Oui, régulièrement": ["var(--success)", "Régulier"],
"Oui, occasionnellement": ["var(--byi-teal-500)", "Occasionnel"],
"Nous sommes en phase de test": ["var(--byi-orange-500)", "Test"],
"Non, mais nous y réfléchissons": ["var(--byi-yellow-500)", "Réflexion"],
"Non, pas du tout": ["var(--byi-coral-500)", "Non"]
};
const [color, label] = map[usage] || ["var(--fg-muted)", usage];
return {label} ;
}
function formatDate(d) {
const days = Math.floor((new Date(2026, 4, 23) - d) / (1000 * 60 * 60 * 24));
if (days === 0) return `Aujourd'hui · ${pad(d.getHours())}:${pad(d.getMinutes())}`;
if (days === 1) return "Hier";
if (days < 7) return `il y a ${days} j`;
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "short" });
}
function pad(n) { return String(n).padStart(2, "0"); }
/* ============================================================
Respondents list
============================================================ */
function AdminRespondents({ data, onOpen }) {
const [search, setSearch] = useStateB("");
const [filter, setFilter] = useStateB("all");
const rows = data.respondents.filter(r => {
const matchSearch = !search ||
[r.company, r.firstName, r.lastName, r.email, ...(r.sectors||[])].join(" ").toLowerCase().includes(search.toLowerCase());
const matchFilter = filter === "all" ||
(filter === "users" && r.isUser) ||
(filter === "non" && !r.isUser) ||
(filter === "intent" && r.wantsFormation !== "Non, pas pour l'instant" && r.trainTotal > 0) ||
(filter === "paid" && (r.licences || []).length > 0);
return matchSearch && matchFilter;
}).sort((a,b)=>b.submittedAt-a.submittedAt);
return (
Tous les répondants
{data.respondents.length} questionnaires reçus
exportRespondentsCSV(rows, data.catalog)}>Exporter en CSV
setSearch(e.target.value)}
/>
{[
["all", `Tous (${data.respondents.length})`],
["users", `Utilisateurs IA (${data.respondents.filter(r=>r.isUser).length})`],
["non", `Non-utilisateurs (${data.respondents.filter(r=>!r.isUser).length})`],
["intent", `Avec intention formation (${data.respondents.filter(r=>r.wantsFormation!=="Non, pas pour l'instant"&&r.trainTotal>0).length})`],
["paid", `Avec licences payantes (${data.respondents.filter(r=>(r.licences||[]).length>0).length})`]
].map(([id, label]) => (
setFilter(id)} className={`resp__tab ${filter===id?"is-active":""}`}>
{label}
))}
Reçu Entreprise Répondant Secteur Eff.
OPCO salariés Usage IA Niv. Pers. Budget
{rows.map(r => (
onOpen(r.id)}>
{formatDate(r.submittedAt)}
{r.company}
{r.firstName} {r.lastName}{r.role}
{r.sectors.join(", ")}
{r.sizeLabel.replace(" salariés","")}
{(r.opcoSalaries || [])[0] || — }
{r.skill}
{r.trainTotal > 0 ? {r.trainTotal} : — }
{r.budget === "unsure" ? — : {r.budgetLabel} }
→
))}
);
}
/* ============================================================
Respondent detail
============================================================ */
function AdminRespondentDetail({ data, id, onBack, onNext, onPrev }) {
const r = data.respondents.find(x => x.id === id);
if (!r) return null;
return (
← Retour à la liste
← précédent
suivant →
Questionnaire · {r.id}
{r.company}
{r.firstName} {r.lastName}
·
{r.role}
·
{r.email}
{r.sectors.map(s => {s} )}
{r.sizeLabel}
OPCO sal. : {r.opcoSalaries.join(", ")}
OPCO dir. : {r.opcoDirigeant.join(", ")}
Reçu {formatDate(r.submittedAt)}
Exporter PDF
Usage actuel (Q5–Q10)
{Array.from({length:10},(_,i)=>(
))}
{r.skill}/10
{r.tools.length
? {r.tools.map(t => {t} )}
: Aucun outil renseigné }
{r.paidStatus}
{r.licences.length > 0 && (
{r.licences.map(l => (
{l.tool} {l.count} sièges
))}
)}
{r.domains.length
? {r.domains.map(t => {t} )}
: — }
{r.benefits.length
? {r.benefits.map(t => {t} )}
: — }
Gouvernance & freins (Q11–Q12)
{r.barriers.length
? {r.barriers.map(t => {t} )}
: — }
{r.pastTraining ? (
Intitulé {r.pastTraining.title}
Organisme {r.pastTraining.organism}
Effectif formé {r.pastTraining.headcount}
Année {r.pastTraining.year}
) : Aucune formation suivie }
Intention de formation (Q15–Q18)
{r.wantsFormation}
{r.trainTotal > 0 && r.trainingPlan && (
Plan de formation par service (Q16)
{Object.entries(r.trainingPlan)
.filter(([, p]) => Number(p.headcount) > 0 || (p.themes||[]).length > 0)
.map(([s, p]) => (
{s}
{p.headcount} apprenant{p.headcount > 1 ? "s" : ""}
{(p.themes||[]).map(m => {m} )}
{(p.themes||[]).length === 0 && Aucun module sélectionné }
))}
TOTAL
{r.trainTotal} apprenants
)}
Modules sélectionnés (Q16)
{r.themes.map(t => {t} )}
{r.themes.length === 0 && Aucun module }
Budget / personne (Q17) {r.budgetLabel}
Décideur (Q18) {r.decider}
Atelier témoignage (Q19) {r.willingToShare}
Potentiel estimé {(r.trainTotal * r.budgetMid).toLocaleString("fr-FR")} €
{r.comment && (
Commentaire libre (Q20)
« {r.comment} »
)}
);
}
function Section({ title, children }) {
return (
);
}
/* ============================================================
Invitations
============================================================ */
function AdminInvitations({ data }) {
const [showAddModal, setShowAddModal] = useStateB(false);
const [showImportModal, setShowImportModal] = useStateB(false);
const [addedMembers, setAddedMembers] = useStateB([]);
const [newMember, setNewMember] = useStateB({
company: "", contact: "", email: "", phone: "", role: "", region: "Île-de-France"
});
const [toast, setToast] = useStateB(null);
// ===== Import state =====
const [importStep, setImportStep] = useStateB("upload"); // upload | preview | done
const [importRows, setImportRows] = useStateB([]); // [{company, firstName, lastName, email, phone, valid, errors}]
const [importFileName, setImportFileName] = useStateB("");
const [importErrorCount, setImportErrorCount] = useStateB(0);
const fileInputRef = React.useRef(null);
const parseCSV = (text) => {
// Robust CSV/TSV parsing — supports ; , or \t delimiters
const lines = text.replace(/\r\n?/g, "\n").split("\n").filter(l => l.trim().length > 0);
if (lines.length === 0) return [];
const detectSep = (line) => {
const counts = { ";": (line.match(/;/g) || []).length,
",": (line.match(/,/g) || []).length,
"\t": (line.match(/\t/g) || []).length };
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
};
const sep = detectSep(lines[0]);
const parseLine = (l) => {
// simple CSV parser respecting double-quoted fields
const out = []; let cur = ""; let inQ = false;
for (let i = 0; i < l.length; i++) {
const c = l[i];
if (inQ) {
if (c === '"' && l[i+1] === '"') { cur += '"'; i++; }
else if (c === '"') inQ = false;
else cur += c;
} else {
if (c === '"') inQ = true;
else if (c === sep) { out.push(cur); cur = ""; }
else cur += c;
}
}
out.push(cur);
return out.map(x => x.trim());
};
const header = parseLine(lines[0]).map(h => h.toLowerCase());
// find columns by name
const findCol = (...names) => {
for (const n of names) {
const i = header.findIndex(h => h.includes(n));
if (i >= 0) return i;
}
return -1;
};
const colCompany = findCol("société", "societe", "entreprise", "company");
const colFirstName = findCol("prénom", "prenom", "first");
const colLastName = findCol("nom", "last");
const colEmail = findCol("mail", "email", "courriel");
const colPhone = findCol("gsm", "tél", "tel", "phone", "mobile");
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const rows = [];
for (let i = 1; i < lines.length; i++) {
const cells = parseLine(lines[i]);
const r = {
company: colCompany >= 0 ? cells[colCompany] || "" : "",
firstName: colFirstName >= 0 ? cells[colFirstName] || "" : "",
lastName: colLastName >= 0 ? cells[colLastName] || "" : "",
email: colEmail >= 0 ? cells[colEmail] || "" : "",
phone: colPhone >= 0 ? cells[colPhone] || "" : "",
};
const errors = [];
if (!r.company) errors.push("Société manquante");
if (!r.email) errors.push("Email manquant");
else if (!emailRe.test(r.email)) errors.push("Email invalide");
r.valid = errors.length === 0;
r.errors = errors;
rows.push(r);
}
return rows;
};
const handleFileSelect = (file) => {
if (!file) return;
setImportFileName(file.name);
const reader = new FileReader();
reader.onload = (e) => {
const rows = parseCSV(e.target.result);
setImportRows(rows);
setImportErrorCount(rows.filter(r => !r.valid).length);
setImportStep("preview");
};
reader.readAsText(file, "UTF-8");
};
const downloadTemplate = () => {
const csv = "\uFEFF" + [
"Société;Prénom;Nom;Email;GSM",
"Imprimerie Lemercier;Camille;Rousseau;c.rousseau@lemercier.fr;06 12 34 56 78",
"Graphi-Print Solutions;Julien;Martin;j.martin@graphiprint.fr;06 98 76 54 32"
].join("\r\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "FESPA-import-adherents-modele.csv";
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const confirmImport = () => {
const valid = importRows.filter(r => r.valid).map(r => ({
company: r.company,
contact: `${r.firstName} ${r.lastName}`.trim(),
email: r.email,
phone: r.phone,
role: "Dirigeant",
region: "Île-de-France"
}));
setAddedMembers(arr => [...valid, ...arr]);
setToast(`✓ ${valid.length} adhérents importés depuis ${importFileName}`);
setShowImportModal(false);
setImportStep("upload");
setImportRows([]);
setImportFileName("");
setTimeout(() => setToast(null), 5000);
};
const closeImport = () => {
setShowImportModal(false);
setImportStep("upload");
setImportRows([]);
setImportFileName("");
setImportErrorCount(0);
};
const [showTestModal, setShowTestModal] = useStateB(false);
const [testEmail, setTestEmail] = useStateB("");
const [testSubject, setTestSubject] = useStateB("Enquête FESPA France — Usage de l'IA dans votre entreprise");
const [testBody, setTestBody] = useStateB(
`Bonjour,
Dans le cadre des travaux de la Commission IA de FESPA France, nous lançons une enquête auprès des adhérents sur l'usage de l'IA.
Temps estimé : 4 minutes — 20 questions
Vos réponses seront analysées de manière anonyme.
Répondre au questionnaire :
https://enquete.fespa-france.fr/r/[lien-unique]
Merci par avance,
La Commission IA — FESPA France`
);
const buildMailto = () => {
if (!testEmail) return "#";
const subject = encodeURIComponent(testSubject);
const body = encodeURIComponent(testBody);
return `mailto:${encodeURIComponent(testEmail)}?subject=${subject}&body=${body}`;
};
const buildGmail = () => {
if (!testEmail) return "#";
const subject = encodeURIComponent(testSubject);
const body = encodeURIComponent(testBody);
return `https://mail.google.com/mail/?view=cm&fs=1&to=${encodeURIComponent(testEmail)}&su=${subject}&body=${body}`;
};
const buildOutlookWeb = () => {
if (!testEmail) return "#";
const subject = encodeURIComponent(testSubject);
const body = encodeURIComponent(testBody);
return `https://outlook.live.com/owa/?path=/mail/action/compose&to=${encodeURIComponent(testEmail)}&subject=${subject}&body=${body}`;
};
const downloadEml = () => {
if (!testEmail) return;
const headers = [
`From: "FESPA France — Commission IA" `,
`To: ${testEmail}`,
`Subject: =?UTF-8?B?${btoa(unescape(encodeURIComponent(testSubject)))}?=`,
`Date: ${new Date().toUTCString()}`,
`MIME-Version: 1.0`,
`Content-Type: text/plain; charset=UTF-8`,
`Content-Transfer-Encoding: 8bit`,
``
].join("\r\n");
const eml = headers + "\r\n" + testBody.replace(/\r?\n/g, "\r\n");
const blob = new Blob([eml], { type: "message/rfc822" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `test-fespa-france.eml`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
setToast(`📄 Fichier .eml téléchargé — double-cliquez dessus pour l'ouvrir dans votre client mail`);
setTimeout(() => setToast(null), 6000);
};
const copyAll = () => {
const txt = `À : ${testEmail}\nObjet : ${testSubject}\n\n${testBody}`;
navigator.clipboard.writeText(txt);
setToast("✓ Email complet copié dans le presse-papier — collez-le dans Brevo / Mailchimp");
setTimeout(() => setToast(null), 4000);
};
const allCompanies = data.catalog.COMPANIES;
// Expand to 416 by suffixing the catalog when needed (FESPA has 416 adherents total)
const expandedCompanies = (() => {
const out = [];
const base = allCompanies;
const cities = ["Paris", "Lyon", "Toulouse", "Bordeaux", "Lille", "Nantes", "Marseille",
"Strasbourg", "Rennes", "Reims", "Tours", "Le Havre", "Grenoble", "Dijon",
"Angers", "Clermont", "Nîmes", "Annecy", "Montpellier", "Aix", "Caen"];
for (let i = 0; i < 416; i++) {
if (i < base.length) {
out.push(base[i]);
} else {
const c = base[i % base.length];
const city = cities[Math.floor(i / base.length) % cities.length];
out.push(`${c} · ${city}`);
}
}
return out;
})();
const baseList = expandedCompanies.map((c, i) => {
const responded = i < data.respondents.length ? data.respondents[i] : null;
return {
company: c,
contact: responded ? `${responded.firstName} ${responded.lastName}` : pickContact(i),
email: responded ? responded.email : (`contact@${c.toLowerCase().replace(/[^a-z]/g,"").slice(0,12)}.fr`),
sentAt: new Date(2026, 4, 23 - 18 - (i % 3)),
reminders: i % 4,
status: responded ? "responded" : (i % 5 === 0 ? "opened" : (i % 7 === 0 ? "bounced" : "pending")),
isNew: false
};
});
const list = [
...addedMembers.map(m => ({ ...m, sentAt: new Date(), reminders: 0, status: "pending", isNew: true })),
...baseList
];
const totals = {
sent: list.length,
responded: list.filter(l => l.status === "responded").length,
opened: list.filter(l => l.status === "opened").length,
pending: list.filter(l => l.status === "pending").length,
bounced: list.filter(l => l.status === "bounced").length
};
const handleAdd = (e) => {
e.preventDefault();
if (!newMember.company || !newMember.email) return;
setAddedMembers(arr => [{ ...newMember }, ...arr]);
setToast(`Adhérent « ${newMember.company} » ajouté · invitation envoyée à ${newMember.email}`);
setNewMember({ company: "", contact: "", email: "", phone: "", role: "", region: "Île-de-France" });
setShowAddModal(false);
setTimeout(() => setToast(null), 4000);
};
return (
Gestion des invitations
Liens personnalisés envoyés aux adhérents
Exporter liste
setShowTestModal(true)}>Tester l'envoi
setShowImportModal(true)}>Importer la base
setShowAddModal(true)}>Ajouter un adhérent
{/* ===== Add member modal ===== */}
{showAddModal && (
)}
{toast && (
{toast}
)}
{/* ===== Test email modal ===== */}
{showTestModal && (
setShowTestModal(false)}>
e.stopPropagation()}>
Test d'envoi d'email
Recevez l'email exactement comme l'adhérent
setShowTestModal(false)}>×
Saisissez votre adresse mail puis cliquez sur « Envoyer le test ».
L'email s'ouvrira dans votre client de messagerie (Outlook, Gmail…) avec le sujet,
le contenu et le lien personnalisé pré-remplis. Il vous suffit de cliquer sur Envoyer
pour le recevoir et vérifier le rendu.
setShowTestModal(false)}>Fermer
)}
{/* ===== Import database modal ===== */}
{showImportModal && (
e.stopPropagation()}>
Importer la base d'adhérents
{importStep === "upload" && "Importer un fichier CSV / Excel"}
{importStep === "preview" && `Aperçu — ${importRows.length} ligne${importRows.length > 1 ? "s" : ""}`}
×
{importStep === "upload" && (
<>
Le fichier doit contenir, au minimum, les colonnes suivantes (l'ordre n'a pas d'importance, les en-têtes sont reconnus automatiquement) :
{[
["Société", "Nom de l'entreprise adhérente", true],
["Prénom", "Prénom du dirigeant / contact", false],
["Nom", "Nom du dirigeant / contact", false],
["Email", "Adresse mail principale", true],
["GSM", "Téléphone mobile", false]
].map(([name, hint, req]) => (
{name}
{req && obligatoire }
{hint}
))}
fileInputRef.current && fileInputRef.current.click()}
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add("is-over"); }}
onDragLeave={e => e.currentTarget.classList.remove("is-over")}
onDrop={e => {
e.preventDefault();
e.currentTarget.classList.remove("is-over");
const f = e.dataTransfer.files[0];
if (f) handleFileSelect(f);
}}>
📥
Cliquez ou déposez votre fichier ici
Formats acceptés : .csv , .txt (Excel : « Enregistrer sous » → CSV UTF-8)
handleFileSelect(e.target.files[0])} />
⬇ Télécharger le modèle CSV
Séparateurs reconnus : virgule, point-virgule, tabulation
Annuler
>
)}
{importStep === "preview" && (
<>
{importRows.length - importErrorCount}
lignes valides
{importErrorCount > 0 && (
{importErrorCount}
ligne{importErrorCount>1?"s":""} en erreur
)}
{importRows.length}
total fichier
📄 {importFileName}
Société Prénom Nom
Email GSM État
{importRows.slice(0, 50).map((r, i) => (
{r.valid ? "✓" : "⚠"}
{r.company || "—"}
{r.firstName || "—"}
{r.lastName || "—"}
{r.email || "—"}
{r.phone || — }
{r.valid
? Prêt
: {r.errors.join(" · ")} }
))}
{importRows.length > 50 && (
… et {importRows.length - 50} autres lignes
)}
setImportStep("upload")}>← Recharger un fichier
✓ Importer {importRows.length - importErrorCount} adhérent{importRows.length - importErrorCount > 1 ? "s" : ""}
>
)}
)}
);
}
function StatusChip({ status }) {
const map = {
responded: ["var(--success)", "Répondu"],
opened: ["var(--byi-orange-500)", "Ouvert · pas répondu"],
pending: ["var(--byi-navy-300)", "En attente"],
bounced: ["var(--byi-coral-500)", "Email invalide"]
};
const [c, l] = map[status];
return {l} ;
}
function pickContact(seed) {
const FIRST = ["Sophie", "Julien", "Camille", "Antoine", "Marie", "Thomas", "Élodie", "Nicolas"];
const LAST = ["Martin", "Bernard", "Dubois", "Petit", "Durand", "Leroy", "Moreau"];
return `${FIRST[seed % FIRST.length]} ${LAST[seed % LAST.length]}`;
}
Object.assign(window, {
AdminLogin, AdminShell, AdminDashboard, AdminRespondents,
AdminRespondentDetail, AdminInvitations,
AdminCampaigns, CampaignClosedNotice,
ADMIN_AREAS, FESPA_CAMPAIGNS
});