// 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")}; }