// gql.jsx — le pont entre le prototype et le vrai moteur.
//
// Chargé AVANT data.jsx : il installe `G4M`, un petit client GraphQL et des
// hooks React. Les écrans passent des mocks aux vraies données en changeant
// une seule ligne (`const x = MOCK` → `const x = useG4M(...)`), ce qui garde
// le prototype navigable même quand le backend dort.
//
// Trois partis pris :
// 1. **Dégradation, pas plantage.** Si le backend est absent, le hook rend le
// mock et le signale (bandeau « mode démonstration »). Un prototype qui
// explose en démo ne se montre à personne.
// 2. **Aucune donnée en dur ici.** Ce fichier ne connaît que la forme des
// requêtes ; le contenu vient du serveur ou du mock d'origine.
// 3. **Le tenant n'est jamais envoyé par le client.** On transmet l'identité
// (courriel de session), le serveur en déduit le tenant. Un front qui
// choisirait son tenant ne serait pas cloisonné.
const G4M = (() => {
const ENDPOINT = (window.G4M_ENDPOINT || "/graphql");
const SESSION_KEY = "grant4me.session.email";
const TOKEN_KEY = "grant4me.session.token";
const state = {
online: null, // null = pas encore testé
email: localStorage.getItem(SESSION_KEY) || "",
token: localStorage.getItem(TOKEN_KEY) || "",
lastError: null,
};
async function call(field, query, variables = {}) {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Le jeton prouve l'identité ; le courriel nu n'est qu'un mode LAB.
...(state.token ? { "Authorization": "Bearer " + state.token }
: state.email ? { "X-User-Email": state.email } : {}),
},
body: JSON.stringify({ query, variables, operation: field }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok || body.errors) {
const msg = (body.errors && body.errors[0] && body.errors[0].message) || `HTTP ${res.status}`;
throw new Error(msg);
}
return body.data ? body.data[field] : null;
}
// Le champ racine suffit à notre exécuteur ; la requête reste écrite en
// entier pour qu'elle documente ce qu'on attend (et qu'elle marche telle
// quelle contre un vrai serveur GraphQL le jour où on en met un).
const Q = {
me: `query { me { userId email fullName role tenantId companyName } }`,
company: `query { company { id legalName neq sector city adminRegion numEmployees legalForm bnFederal scianCode annualRevenueRange address productsOffered growthObjectives dateConstitution contactName contactTitle contactPhone email financingNeeds activityCodes profileComplete } }`,
projects: `query { projects { id title description sector formDomain startDate endDate totalValue requestedAmount problemStatement objectives methodology expectedResults companyName } }`,
runs: `query($limit:Int){ runs(limit:$limit){ id projectId projectTitle companyName status progressPct createdAt detectedFields pctAutoFilled pctHumanValidated } }`,
run: `query($id:ID!){ run(id:$id){ id status progressPct detectedFields error resultUrl
missingFields { fieldName question fieldType suggestedValue whyMissing choices }
reviewFields { fieldName label value origin fieldType choices }
requiredDocs { label description mandatory sourceInForm }
pctAutoFilled pctHumanValidated } }`,
formDomains: `query { formDomains { slug labels lexicon defaultLang dateFormat status referencePrograms } }`,
activeDomain: `query($projectId:ID){ activeDomain(projectId:$projectId){ slug labels lexicon defaultLang dateFormat status referencePrograms } }`,
project: `query($id:ID!){ project(id:$id){ id title description sector problemStatement objectives methodology expectedResults startDate endDate totalValue requestedAmount companyName } }`,
caeSearch: `query($q:String!){ caeSearch(q:$q){ code libelle } }`,
platformUsage: `query { platformUsage { tenantId companyName month runs runsCompleted saasUnits fields questions memoryHits llmCalls llmTokensIn llmTokensOut avgDurationMs } }`,
saveCompany: `mutation($id:ID!,$input:JSON!){ saveCompany(id:$id,input:$input){ id legalName sector city profileComplete } }`,
saveProject: `mutation($id:ID!,$input:JSON!){ saveProject(id:$id,input:$input){ id title objectives } }`,
createProject: `mutation($title:String,$description:String){ createProject(title:$title,description:$description){ id title } }`,
startRun: `mutation($projectId:ID!,$sourcePdfBase64:String!,$lang:String,$formDomain:String){
startRun(projectId:$projectId,sourcePdfBase64:$sourcePdfBase64,lang:$lang,formDomain:$formDomain){ id status progressPct } }`,
provideAnswers: `mutation($runId:ID!,$answers:JSON!,$validated:[String!]){
provideAnswers(runId:$runId,answers:$answers,validated:$validated){ id status progressPct } }`,
login: `mutation($email:String!,$password:String!){ login(email:$email,password:$password){ token me { userId email fullName role tenantId companyName } } }`,
logout: `mutation($token:String){ logout(token:$token) }`,
confirmReview: `mutation($runId:ID!,$corrections:JSON,$validated:[String!]!){
confirmReview(runId:$runId,corrections:$corrections,validated:$validated){ id status progressPct } }`,
};
const api = {
get email() { return state.email; },
setEmail(e) {
state.email = e || "";
try { localStorage.setItem(SESSION_KEY, state.email); } catch (err) {}
},
get online() { return state.online; },
get lastError() { return state.lastError; },
async probe() {
try {
await call("me", Q.me);
state.online = true;
} catch (e) {
state.online = false;
state.lastError = e.message;
}
return state.online;
},
async login(email, password) {
const r = await call("login", Q.login, { email, password });
state.token = r.token; api.setEmail(email);
try { localStorage.setItem(TOKEN_KEY, r.token); } catch (e) {}
return r.me;
},
async logout() {
try { await call("logout", Q.logout, { token: state.token }); } catch (e) {}
state.token = "";
try { localStorage.removeItem(TOKEN_KEY); } catch (e) {}
},
get token() { return state.token; },
me: () => call("me", Q.me),
company: () => call("company", Q.company),
projects: () => call("projects", Q.projects),
runs: (limit = 20) => call("runs", Q.runs, { limit }),
run: (id) => call("run", Q.run, { id }),
formDomains: () => call("formDomains", Q.formDomains),
activeDomain: (projectId) => call("activeDomain", Q.activeDomain, { projectId: projectId || null }),
project: (id) => call("project", Q.project, { id }),
caeSearch: (q) => call("caeSearch", Q.caeSearch, { q }),
platformUsage: () => call("platformUsage", Q.platformUsage),
saveCompany: (id, input) => call("saveCompany", Q.saveCompany, { id, input }),
saveProject: (id, input) => call("saveProject", Q.saveProject, { id, input }),
createProject: (title, description) => call("createProject", Q.createProject, { title, description }),
startRun: (v) => call("startRun", Q.startRun, v),
provideAnswers: (v) => call("provideAnswers", Q.provideAnswers, v),
confirmReview: (v) => call("confirmReview", Q.confirmReview, v),
/** Lit un PDF choisi dans un en base64 (sans le préfixe
* data:), la forme que l'API du moteur attend. */
fileToBase64(file) {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result).split(",")[1] || "");
r.onerror = reject;
r.readAsDataURL(file);
});
},
};
return api;
})();
/** Hook : données réelles si le backend répond, mock sinon.
* Retourne [valeur, meta] où meta = { loading, live, error }. */
function useG4M(fetcher, fallback, deps = []) {
const [value, setValue] = React.useState(fallback);
const [meta, setMeta] = React.useState({ loading: true, live: false, error: null });
React.useEffect(() => {
let alive = true;
(async () => {
try {
const v = await fetcher();
if (!alive) return;
// Une liste vide côté serveur reste une réponse valide : on n'invente
// pas des données pour « faire joli ».
setValue(v == null ? fallback : v);
setMeta({ loading: false, live: true, error: null });
} catch (e) {
if (!alive) return;
setValue(fallback);
setMeta({ loading: false, live: false, error: e.message });
}
})();
return () => { alive = false; };
}, deps);
return [value, meta];
}
/** Suit un run jusqu'à une étape où l'humain reprend la main. Le polling
* s'arrête de lui-même : un onglet oublié ne doit pas marteler le serveur. */
function useRunPolling(runId, { intervalMs = 2000, onPhase } = {}) {
const [run, setRun] = React.useState(null);
const [err, setErr] = React.useState(null);
React.useEffect(() => {
if (!runId) return;
let alive = true, timer = null;
const TERMINAL = ["awaiting_user", "awaiting_review", "done", "failed"];
const tick = async () => {
try {
const r = await G4M.run(runId);
if (!alive) return;
setRun(r);
if (onPhase && r) onPhase(r);
if (r && TERMINAL.includes(r.status)) return;
} catch (e) {
if (!alive) return;
setErr(e.message);
}
timer = setTimeout(tick, intervalMs);
};
tick();
return () => { alive = false; if (timer) clearTimeout(timer); };
}, [runId]);
return [run, err];
}
/** Recherche CAE contre le VRAI référentiel, avec repli sur le jeu embarqué
* si le backend dort. Débounce court : on interroge la base à la frappe, pas
* à chaque touche. */
function useCaeSearch() {
const cache = React.useRef({});
const [, force] = React.useState(0);
return React.useCallback((q) => {
const key = (q || "").trim().toLowerCase();
if (!key) return [];
if (cache.current[key]) return cache.current[key];
cache.current[key] = []; // évite les appels en rafale
G4M.caeSearch(key)
.then((rows) => { cache.current[key] = rows || []; force((n) => n + 1); })
.catch(() => { cache.current[key] = (typeof searchCae === "function" ? searchCae(key) : []); force((n) => n + 1); });
return typeof searchCae === "function" ? searchCae(key) : [];
}, []);
}
/** Bandeau discret : dit la vérité sur l'origine de ce qui est affiché.
* Un écran de démonstration qui se fait passer pour du réel est un piège
* qu'on se tend à soi-même. */
function LiveBadge({ meta }) {
if (!meta || meta.loading) return null;
if (meta.live) {
return
{I18N.t("données réelles")};
}
return
{I18N.t("mode démonstration")};
}