Kazeia-central/kazeia_central/web/app.js

566 lines
49 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
/* Kazeia-central — shell d'application (clinique-apaisé, sans build). */
const S = { initialized: false, unlocked: false, devices: [], selected: null, section: "flotte" };
// ---- helpers --------------------------------------------------------------
const $ = (s, r = document) => r.querySelector(s);
function elFrom(html) { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstChild; }
async function api(path, opts) {
const r = await fetch("/api" + path, opts);
if (!r.ok) { let d = r.status; try { d = (await r.json()).detail || d; } catch {} const e = new Error(d); e.status = r.status; throw e; }
return r.status === 204 ? null : r.json();
}
function toast(msg, kind = "info", ms = 3800) {
const t = elFrom(`<div class="toast ${kind}">${msg}</div>`); $("#toasts").appendChild(t);
setTimeout(() => t.remove(), ms);
}
const esc = (s) => (s == null ? "" : String(s)).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const badge = (txt, cls = "") => `<span class="badge ${cls}">${esc(txt)}</span>`;
const dot = (cls) => `<span class="dot ${cls}"></span>`;
const fmtTs = (ms) => ms ? new Date(ms).toLocaleString("fr-FR") : "—";
const scopeBadge = (s) => badge(s || "?", { exclusive: "warn", global: "ok", pending: "" }[s] || "");
function deviceLabel(serial) { const d = S.devices.find(x => x.serial === serial); return d && d.label ? d.label : serial; }
async function withBusy(btn, fn) { const o = btn.textContent; btn.disabled = true; btn.innerHTML = `<span class="spinner"></span>`; try { return await fn(); } finally { btn.disabled = false; btn.textContent = o; } }
// ---- sections -------------------------------------------------------------
const SECTIONS = [
{ group: "🔧 Technique", domain: "tech" },
{ id: "flotte", ic: "🛰", label: "Flotte", render: renderFlotte },
{ id: "voix", ic: "🎙", label: "Voix", render: renderVoix },
{ id: "updates", ic: "⬆", label: "Mises à jour", render: renderUpdates },
{ id: "config", ic: "⚙", label: "Config & moteur", render: () => stub("Config & moteur", "Config sampling (température, presets…) et prompts moteur, avec push par tablette. Dépend des méthodes call() base64-JSON côté app patiente (PROVIDER_RPC_SPEC).") },
{ id: "audit", ic: "📋", label: "Audit", render: renderAudit },
{ group: "⚕️ Médical / clinique", domain: "med" },
{ id: "patients", ic: "👤", label: "Patients & profils", render: renderPatients },
{ id: "conversations", ic: "💬", label: "Conversations", render: renderConversations },
{ id: "rag", ic: "📚", label: "RAG thérapeutique", render: renderRag },
{ id: "questionnaires", ic: "📝", label: "Questionnaires", render: renderQuestionnaires },
];
// ---- boot + shell ---------------------------------------------------------
boot();
async function boot() {
try { const st = await api("/lock-status"); S.initialized = st.initialized; S.unlocked = st.unlocked; }
catch (e) { $("#app").innerHTML = `<div class="gate"><div class="panel"><div class="logo">Kazeia<span class="dot">·</span>central</div><p class="err">serveur injoignable : ${esc(e.message)}</p></div></div>`; return; }
if (!S.unlocked) return renderGate();
await renderShell();
}
function renderGate() {
const first = !S.initialized;
$("#app").innerHTML = `<div class="gate"><div class="panel">
<div class="logo">Kazeia<span class="dot">·</span>central</div>
<p>${first ? "Choisis un mot de passe opérateur (première fois)" : "Déverrouille le coffre opérateur"}</p>
<input id="pw" type="password" placeholder="mot de passe" autofocus>
<button class="btn-primary" id="gobtn">${first ? "Initialiser" : "Déverrouiller"}</button>
<div id="gerr" class="err" style="margin-top:10px"></div>
</div></div>`;
const go = async () => {
try { await api("/unlock", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ password: $("#pw").value }) }); S.unlocked = true; await renderShell(); }
catch (e) { $("#gerr").textContent = e.message; }
};
$("#gobtn").onclick = go;
$("#pw").onkeydown = (e) => { if (e.key === "Enter") go(); };
}
async function renderShell() {
$("#app").innerHTML = `
<div class="topbar">
<span class="brand">Kazeia<span class="dot">·</span>central</span>
<span class="ctx">Tablette : <select id="devsel"></select></span>
<span class="spacer"></span>
<span class="lockpill on" id="lockpill">🔓 coffre ouvert</span>
<button class="btn-sm btn-ghost" onclick="lockStore()">verrouiller</button>
</div>
<nav class="sidebar" id="sidebar"></nav>
<main><div id="content"></div></main>`;
$("#sidebar").innerHTML = SECTIONS.map(s => s.group
? `<div class="nav-label ${s.domain === "med" ? "dom-med" : "dom-tech"}">${s.group}</div>`
: `<div class="nav-item" data-id="${s.id}"><span class="ic">${s.ic}</span>${s.label}</div>`).join("");
$("#sidebar").querySelectorAll(".nav-item").forEach(n => n.onclick = () => { S.section = n.dataset.id; renderSection(); });
await loadDevices();
renderSection();
}
async function loadDevices() {
try { S.devices = await api("/devices"); } catch (e) { S.devices = []; toast("erreur adb : " + e.message, "err"); }
if (S.devices.length && !S.selected) S.selected = S.devices[0].serial;
const sel = $("#devsel");
if (sel) {
sel.innerHTML = `<option value="">— aucune —</option>` + S.devices.map(d =>
`<option value="${d.serial}" ${d.serial === S.selected ? "selected" : ""}>${esc(d.label || d.serial)} ${d.state !== "device" ? "(" + d.state + ")" : ""}</option>`).join("");
sel.onchange = () => { S.selected = sel.value || null; renderSection(); };
}
}
function renderSection() {
document.querySelectorAll(".nav-item").forEach(n => n.classList.toggle("active", n.dataset.id === S.section));
const sec = SECTIONS.find(s => s.id === S.section) || SECTIONS.find(s => s.render);
const c = $("#content"); c.innerHTML = `<div class="empty"><span class="spinner"></span></div>`;
Promise.resolve(sec.render(c)).catch(e => { c.innerHTML = `<div class="card"><div class="err">${esc(e.message)}</div></div>`; });
}
function pageHead(title, sub, actions = "") {
return `<div class="page-head"><div><h1>${title}</h1>${sub ? `<div class="sub">${sub}</div>` : ""}</div><div class="actions">${actions}</div></div>`;
}
function stub(title, desc) {
return `${pageHead(title)}<div class="card"><div class="empty"><div class="big">🚧</div><p>${esc(desc)}</p><p class="muted">Section prévue par l'architecture — non encore implémentée.</p></div></div>`;
}
// ---- FLOTTE ---------------------------------------------------------------
async function renderFlotte(c) {
const rep = await api("/fleet/overview").catch(e => ({ error: e.message }));
const rows = Array.isArray(rep) ? rep : [];
const head = pageHead("Flotte", `${S.devices.length} tablette(s)`,
`<button class="btn-sm" onclick="renderSection()">↻ Rafraîchir</button>`);
const table = rep.error ? `<div class="err">${esc(rep.error)}</div>` : rows.length ? `<table>
<tr><th>tablette</th><th>état</th><th>RAM</th><th>MAJ</th><th>RAG</th><th>profils</th><th>sessions</th><th>crashes</th></tr>
${rows.map(r => { const o = r.result || {}; const sel = r.serial === S.selected;
return `<tr style="cursor:pointer;${sel ? "background:var(--primary-soft)" : ""}" onclick="selectDevice('${r.serial}')">
<td><b>${esc(r.label || r.serial)}</b><div class="muted mono">${esc(r.serial)}</div></td>
${r.ok === false ? `<td colspan="7" class="err">${esc(r.error)}</td>` : `
<td>${dot(o.online ? "ok" : "off")} ${o.online ? "en ligne" : "hors-ligne"}</td>
<td>${o.mem_available_mb ?? "—"} Mo</td>
<td>${esc(o.update_phase || "—")}${o.app_update_available ? " ⬆" : ""}</td>
<td>${dot(o.rag_ready ? "ok" : "idle")} ${o.rag_docs ?? 0}</td>
<td>${o.profiles ?? 0}</td><td>${o.sessions ?? 0}</td>
<td>${o.crashes ? `<span class="err">${o.crashes}</span>` : 0}</td>`}</tr>`; }).join("")}
</table>` : `<div class="empty">aucune tablette branchée</div>`;
c.innerHTML = head + `<div class="card full">${table}</div><div id="devdetail"></div>`;
if (S.selected) renderDeviceDetail($("#devdetail"), S.selected);
}
function selectDevice(serial) { S.selected = serial; const sel = $("#devsel"); if (sel) sel.value = serial; renderSection(); }
async function renderDeviceDetail(host, serial) {
host.innerHTML = `<div class="page-head" style="margin-top:22px"><h1 style="font-size:16px">Détail · ${esc(deviceLabel(serial))}</h1></div><div class="grid" id="ddgrid"><div class="empty"><span class="spinner"></span></div></div>`;
const get = (p, fn) => api(`/devices/${serial}/${p}`).then(fn).catch(e => `<div class="card"><h3>${p}</h3><div class="err">${esc(e.message)}</div></div>`);
const cards = await Promise.all([
get("state", s => card("État runtime", kv("pid", s.pid) + kv("uptime (s)", s.uptime_seconds) + kv("PSS (Mo)", s.pss_mb) + kv("RAM dispo (Mo)", s.mem_available_mb))),
get("updates", u => card("Mises à jour", kv("phase", u.phase) + kv("MAJ dispo", u.app_update_available ? "oui" : "non") + kv("version", u.app_version_name || "—"))),
get("rag/status", r => card("RAG", kv("état", (r.ready ? "prêt" : "non prêt")) + kv("modèle", r.model || "—") + kv("docs", r.doc_count ?? 0))),
get("profiles", ps => card("Profils (" + ps.length + ")", ps.length ? `<table><tr><th>nom</th><th>voix</th><th>PIN</th><th>tours</th></tr>${ps.map(p => `<tr><td>${esc(p.display_name || p.id)}</td><td>${esc(p.voice_id || "—")}</td><td>${p.has_pin ? "🔒" : "—"}</td><td>${p.turn_count ?? 0}</td></tr>`).join("")}</table>` : `<span class="muted">aucun</span>`, "full")),
get("conversations", cs => card("Conversations (" + cs.length + ")", cs.length ? `<table><tr><th>profil</th><th>session</th><th>tours</th><th>début</th></tr>${cs.map(x => `<tr><td>${esc(x.profile_id)}</td><td class="mono">${esc(x.session_id)}</td><td>${x.turn_count ?? 0}</td><td>${fmtTs(x.started_at)}</td></tr>`).join("")}</table>` : `<span class="muted">aucune session</span>`, "full")),
get("crashes", cr => card("Crashes (" + cr.length + ")", cr.length ? cr.map(x => kv(x.component || "?", x.message || "")).join("") : `<span class="muted">aucun</span>`)),
]);
$("#ddgrid").innerHTML = cards.join("");
}
const card = (title, body, cls = "") => `<div class="card ${cls}"><h3>${title}</h3>${body}</div>`;
const kv = (k, v) => `<div class="kv"><span class="k">${k}</span><span class="v">${v ?? "—"}</span></div>`;
// ---- MISES À JOUR ---------------------------------------------------------
async function renderUpdates(c) {
const rep = await api("/fleet/overview").catch(e => ({ error: e.message }));
const rows = Array.isArray(rep) ? rep : [];
c.innerHTML = pageHead("Mises à jour", "OTA piloté par tablette",
`<button class="btn-sm" id="chk">Vérifier le parc</button> <button class="btn-sm btn-primary" id="inst">Installer le parc</button>`)
+ `<div class="card full">${rep.error ? `<div class="err">${esc(rep.error)}</div>` : `<table><tr><th>tablette</th><th>phase</th><th>MAJ dispo</th><th>version</th></tr>
${rows.map(r => { const o = r.result || {}; return `<tr><td><b>${esc(r.label || r.serial)}</b></td><td>${esc(o.update_phase || "—")}</td><td>${o.app_update_available ? badge("disponible", "warn") : "—"}</td><td>${esc(o.app_version_name || "—")}</td></tr>`; }).join("")}</table>`}</div>`;
$("#chk").onclick = (e) => withBusy(e.target, async () => { const r = await api("/fleet/update-check", { method: "POST" }); toast(`${r.filter(x => x.ok).length}/${r.length} ont accepté la vérification`, "ok"); });
$("#inst").onclick = (e) => { if (!confirm("Lancer l'installation OTA sur tout le parc ?")) return; withBusy(e.target, async () => { const r = await api("/fleet/update-install", { method: "POST" }); toast(`installation lancée sur ${r.filter(x => x.ok).length}/${r.length}`, "ok"); }); };
}
// ---- AUDIT ----------------------------------------------------------------
async function renderAudit(c) {
const log = await api("/audit").catch(e => ({ error: e.message }));
const rows = Array.isArray(log) ? log : [];
c.innerHTML = pageHead("Audit", "piste d'accès opérateur (§8)") + `<div class="card full">${log.error ? `<div class="err">${esc(log.error)}</div>` : rows.length ? `<table><tr><th>date</th><th>acteur</th><th>action</th><th>cible</th></tr>
${rows.map(a => `<tr><td>${fmtTs(a.ts)}</td><td>${esc(a.actor || "—")}</td><td>${badge(a.action, "primary")}</td><td class="mono">${esc(a.target || "—")}</td></tr>`).join("")}</table>` : `<div class="empty">aucune entrée</div>`}</div>`;
}
// ---- VOIX -----------------------------------------------------------------
async function renderVoix(c) {
const serial = S.selected;
const reqs = [api("/voices/catalog").catch(e => ({ error: e.message })), api("/voices/autosync").catch(e => ({ error: e.message }))];
if (serial) reqs.push(api(`/voices/${serial}`).catch(e => ({ error: e.message })), api(`/voices/${serial}/admin`).catch(e => ({ error: e.message })));
const [cat, auto, tablet, admin] = await Promise.all(reqs);
const cv = Array.isArray(cat) ? cat : [];
const catCard = card(`Catalogue voix — flotte (${cv.length})`, cat.error ? `<div class="err">${esc(cat.error)}</div>` :
cv.length ? `<table><tr><th>voix</th><th>portée</th><th>verrou</th><th>déployée sur</th><th></th></tr>
${cv.map(v => `<tr><td><b>${esc(v.name || v.voice_id)}</b><div class="muted mono">${esc(v.voice_id)}</div></td>
<td>${scopeBadge(v.scope)}</td>
<td>${v.locked_profile_id ? "🔒 " + esc(v.owner_name || v.locked_profile_id) : `<span class="muted">généraliste</span>`}</td>
<td class="muted">${(v.deployed_serials || []).map(deviceLabel).map(esc).join(", ") || "—"}</td>
<td style="white-space:nowrap">${v.locked_profile_id ? `<button class="btn-sm" onclick="unlockVoice('${v.voice_id}')">déverrouiller</button>` : `<button class="btn-sm" onclick="lockVoicePrompt('${v.voice_id}')">verrouiller</button>`}
${v.has_wav ? `<button class="btn-sm" onclick="reenrollVoice(this,'${v.voice_id}')">ré-enrôler</button>` : ""}</td></tr>`).join("")}
</table>` : `<div class="empty">catalogue vide — enregistre des voix via l'app admin puis synchronise</div>`, "full");
const autoCard = card("Déclencheur automatique", auto.error ? `<div class="err">${esc(auto.error)}</div>` :
`<div class="kv"><span class="k">état</span><span class="v">${dot(auto.enabled ? "ok" : "idle")} ${auto.enabled ? "armé" : "désarmé"}</span></div>
<div style="display:flex;gap:10px;align-items:center;margin-top:10px">
<button onclick="toggleAutosync(${!auto.enabled})">${auto.enabled ? "Désarmer" : "Armer"}</button>
<label class="chk"><input type="checkbox" id="auto_del" ${auto.delete_source ? "checked" : ""}> supprimer le WAV device après</label></div>`);
let cards = catCard + autoCard;
if (!serial) {
cards += card("Par tablette", `<div class="empty">Choisis une tablette (en haut) pour voir ce qui y est chargé et les voix chargeables dessus.</div>`, "full");
} else {
const av = Array.isArray(admin) ? admin : [];
const adminCard = card(`Voix admin à enrôler — ${esc(deviceLabel(serial))} (${av.length})`, admin.error ? `<div class="err">${esc(admin.error)}</div>` :
(av.length ? `<table><tr><th>nom</th><th>portée</th><th>propriétaire</th><th>statut</th></tr>${av.map(v => `<tr><td><b>${esc(v.name || v.voice_id)}</b></td><td>${scopeBadge(v.scope)}</td><td>${esc(v.owner_name || v.owner_profile_id || "—")}</td><td>${esc(v.status)}</td></tr>`).join("")}</table>` : `<div class="empty">aucune voix admin en attente</div>`)
+ `<div style="display:flex;gap:10px;align-items:center;margin-top:12px"><button class="btn-primary" onclick="syncAdmin(this,'${serial}')">Synchroniser (enrôler + déployer)</button><label class="chk"><input type="checkbox" id="sync_del"> supprimer le WAV device après</label></div>`, "full");
const loaded = (tablet && tablet.deployed) || [], loadable = (tablet && tablet.deployable) || [];
const tabCard = card(`${esc(deviceLabel(serial))} — chargées (${loaded.length}) · chargeables (${loadable.filter(v => v.deployable).length})`, tablet && tablet.error ? `<div class="err">${esc(tablet.error)}</div>` :
`<table><tr><th>voix</th><th>portée</th><th>état</th><th></th></tr>
${loaded.map(v => `<tr><td>${dot("ok")} ${esc(v.name || v.voice_id)}${v.in_catalog === false ? ` <span class="muted">(hors catalogue)</span>` : ""}</td><td>${v.scope ? scopeBadge(v.scope) : "—"}</td><td>chargée</td><td>${v.in_catalog !== false ? `<button class="btn-sm" onclick="undeployVoice(this,'${serial}','${v.voice_id}')">retirer</button>` : ""}</td></tr>`).join("")}
${loadable.map(v => `<tr><td class="muted">${esc(v.name || v.voice_id)}</td><td>${scopeBadge(v.scope)}</td><td class="muted">${v.deployable ? "chargeable" : esc(v.reason || "—")}</td><td>${v.deployable ? `<button class="btn-sm btn-primary" onclick="deployVoice(this,'${serial}','${v.voice_id}')">charger</button>` : ""}</td></tr>`).join("")}
</table>`, "full");
cards += adminCard + tabCard;
}
c.innerHTML = pageHead("Voix", "catalogue de flotte · enrôlement OmniVoice") + `<div class="grid">${cards}</div>`;
}
// ---- actions voix ---------------------------------------------------------
async function toggleAutosync(on) { try { await api("/voices/autosync", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled: on, delete_source: $("#auto_del") && $("#auto_del").checked || false }) }); toast(on ? "auto-enrôlement armé" : "désarmé", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }
async function syncAdmin(btn, serial) { const del = $("#sync_del") && $("#sync_del").checked || false; await withBusy(btn, async () => { try { const r = await api(`/voices/${serial}/admin/sync`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ delete_source: del }) }); toast(`${r.filter(x => x.ok && x.deployed).length}/${r.length} voix déployée(s)`, "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); }
async function deployVoice(btn, serial, vid) { await withBusy(btn, async () => { try { const r = await api(`/voices/${serial}/deploy`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ voice_id: vid }) }); r.deployed ? toast("voix chargée", "ok") : toast("non chargée : " + (r.reason || ""), "err"); renderSection(); } catch (e) { toast(e.message, "err"); } }); }
async function undeployVoice(btn, serial, vid) { await withBusy(btn, async () => { try { await api(`/voices/${serial}/undeploy`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ voice_id: vid }) }); toast("voix retirée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); }
async function reenrollVoice(btn, vid) { if (!confirm(`Ré-enrôler « ${vid} » (fix §7) et re-pousser sur ses tablettes ?`)) return; await withBusy(btn, async () => { try { const r = await api(`/voices/catalog/${vid}/reenroll`, { method: "POST" }); toast("ré-enrôlé · re-déployé : " + ((r.redeployed || []).join(", ") || "—"), "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); }
async function lockVoicePrompt(vid) { const p = prompt(`Verrouiller « ${vid} » sur quel profil patient ? (profile_id)`); if (!p) return; try { await api(`/voices/catalog/${vid}/lock`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ profile_id: p }) }); toast("voix verrouillée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }
async function unlockVoice(vid) { try { await api(`/voices/catalog/${vid}/unlock`, { method: "POST" }); toast("voix déverrouillée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }
// ---- PATIENTS (médical) ---------------------------------------------------
const LANGS = ["fr", "en", "de", "es", "it", "pt", "nl", "ar", "zh", "ja", "ko"];
async function renderPatients(c) {
const serial = S.selected;
if (!serial) { c.innerHTML = pageHead("Patients & profils") + `<div class="card"><div class="empty"><div class="big">👤</div><p>Choisis une tablette (en haut) pour voir ses patients.</p></div></div>`; return; }
const list = await api(`/patients/${serial}`).catch(e => ({ error: e.message }));
const ps = Array.isArray(list) ? list : [];
const table = list.error ? `<div class="err">${esc(list.error)}</div>` : ps.length ? `<table>
<tr><th>patient</th><th>voix</th><th>langue</th><th>fiche</th><th>tours</th><th></th></tr>
${ps.map(p => `<tr><td><b>${esc(p.full_name || p.display_name || p.profile_id)}</b><div class="muted mono">${esc(p.profile_id)}</div></td>
<td>${esc(p.voice_id || "—")}</td><td>${p.language ? badge(p.language, "primary") : `<span class="muted">—</span>`}</td>
<td>${p.has_fiche ? badge("remplie", "ok") : `<span class="muted">vide</span>`}</td><td>${p.turn_count ?? 0}</td>
<td><button class="btn-sm" onclick="openPatient('${serial}','${p.profile_id}')">ouvrir</button></td></tr>`).join("")}
</table>` : `<div class="empty">aucun profil patient sur cette tablette</div>`;
c.innerHTML = pageHead("Patients & profils", esc(deviceLabel(serial))) + `<div class="card full">${table}</div><div id="patdetail"></div>`;
}
const finput = (id, label, val) => `<label style="display:flex;align-items:center;gap:10px;margin:5px 0"><span style="width:150px;color:var(--muted);font-size:13px">${label}</span><input id="${id}" value="${esc(val || "")}" style="flex:1"></label>`;
const ftext = (id, label, val) => `<div style="margin:8px 0"><div class="muted" style="font-size:12px;margin-bottom:3px">${label}</div><textarea id="${id}" rows="3" style="width:100%;font:inherit;padding:8px 11px;border:1px solid var(--line-strong);border-radius:8px;resize:vertical">${esc(val || "")}</textarea></div>`;
const subhead = (t) => `<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.05em;margin:12px 0 6px;font-weight:700">${t}</div>`;
async function openPatient(serial, pid) {
const host = $("#patdetail"); host.innerHTML = `<div class="empty"><span class="spinner"></span></div>`;
let d; try { d = await api(`/patients/${serial}/${pid}`); } catch (e) { host.innerHTML = `<div class="card"><div class="err">${esc(e.message)}</div></div>`; return; }
const prof = d.profile || {}, f = d.fiche || {}, civ = f.civil || {}, con = f.contact || {}, cli = f.clinical || {};
const curLang = d.language || prof.language || "fr";
const qres = await api(`/questionnaires/results/${serial}/${pid}`).catch(() => []);
const qresCard = card("Questionnaires cliniques <span class='badge'>chiffrés · PC</span>",
(Array.isArray(qres) && qres.length) ? `<table><tr><th>questionnaire</th><th>date</th><th>score</th><th>interprétation</th></tr>
${qres.map(r => `<tr><td>${esc(r.questionnaire_id)}</td><td class="muted">${fmtTs(r.completed_at)}</td>
<td><b>${r.result.total_score ?? "—"}</b></td><td>${r.result.interpretation ? badge(r.result.interpretation, "primary") : "—"}</td></tr>`).join("")}
</table>` : `<div class="muted">aucun résultat — assigne/saisis un questionnaire dans la section Questionnaires</div>`, "full");
host.innerHTML = `<div class="page-head" style="margin-top:22px"><h1 style="font-size:16px">Patient · ${esc(prof.display_name || pid)}</h1></div><div class="grid">
${card("Profil on-device (tablette)", kv("nom affiché", esc(prof.display_name || "—")) + kv("voix", esc(prof.voice_id || "—")) + kv("PIN", prof.has_pin ? "🔒 défini" : "—")
+ `<div class="kv"><span class="k">langue du pipeline</span><span class="v"><select id="lang">${LANGS.map(l => `<option ${l === curLang ? "selected" : ""}>${l}</option>`).join("")}</select> <button class="btn-sm btn-primary" onclick="pushLanguage(this,'${serial}','${pid}')">pousser</button></span></div>`
+ (prof.system_prompt_override ? `<div class="muted" style="margin-top:8px;font-size:12px">prompt override : ${esc((prof.system_prompt_override || "").slice(0, 90))}…</div>` : ""))}
${card("Fiche clinique <span class='badge'>chiffrée · PC uniquement</span>",
subhead("Identité civile") + finput("civ_name", "Nom complet", civ.full_name) + finput("civ_birth", "Naissance", civ.birth_date) + finput("civ_sex", "Sexe", civ.sex)
+ subhead("Contact / référent") + finput("con_ref", "Soignant référent", con.referent) + finput("con_fam", "Proche / famille", con.family) + finput("con_tel", "Téléphone", con.phone)
+ subhead("Clinique") + finput("cli_path", "Pathologie / contexte", cli.pathology) + finput("cli_treat", "Traitement", cli.treatment) + finput("cli_all", "Allergies", cli.allergies) + ftext("cli_notes", "Notes cliniques", cli.notes)
+ `<div style="margin-top:12px"><button class="btn-primary" onclick="saveFiche(this,'${serial}','${pid}')">Enregistrer la fiche</button></div>`, "full")}
${qresCard}
</div>`;
}
async function saveFiche(btn, serial, pid) {
const gv = (id) => (($("#" + id) || {}).value || "").trim();
const fiche = {
civil: { full_name: gv("civ_name"), birth_date: gv("civ_birth"), sex: gv("civ_sex") },
contact: { referent: gv("con_ref"), family: gv("con_fam"), phone: gv("con_tel") },
clinical: { pathology: gv("cli_path"), treatment: gv("cli_treat"), allergies: gv("cli_all"), notes: gv("cli_notes") },
};
const language = ($("#lang") || {}).value || null;
await withBusy(btn, async () => { try { await api(`/patients/${serial}/${pid}/fiche`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ fiche, language }) }); toast("fiche enregistrée (chiffrée)", "ok"); } catch (e) { toast(e.message, "err"); } });
}
async function pushLanguage(btn, serial, pid) {
const language = ($("#lang") || {}).value;
await withBusy(btn, async () => { try { await api(`/patients/${serial}/${pid}/language`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ language }) }); toast(`langue « ${language} » poussée sur la tablette`, "ok"); } catch (e) { toast(e.message, "err"); } });
}
// ---- CONVERSATIONS (médical, RGPD) ----------------------------------------
async function renderConversations(c) {
const serial = S.selected;
if (!serial) { c.innerHTML = pageHead("Conversations") + `<div class="card"><div class="empty"><div class="big">💬</div><p>Choisis une tablette (en haut).</p></div></div>`; return; }
const list = await api(`/conversations/${serial}`).catch(e => ({ error: e.message }));
const ss = Array.isArray(list) ? list : [];
const table = list.error ? `<div class="err">${esc(list.error)}</div>` : ss.length ? `<table>
<tr><th>session</th><th>patient</th><th>tours</th><th>début</th><th></th></tr>
${ss.map(s => `<tr><td class="mono">${esc(s.session_id)}</td><td>${esc(s.profile_id || "—")}</td><td>${s.turns}</td>
<td class="muted">${fmtTs(s.started_at)}</td>
<td style="white-space:nowrap"><button class="btn-sm" onclick="openSession('${serial}','${s.session_id}')">lire</button>
<button class="btn-sm" onclick="exportSession('${serial}','${s.session_id}')">JSON</button>
<button class="btn-sm" onclick="purgeSession(this,'${serial}','${s.session_id}')">purger</button></td></tr>`).join("")}
</table>` : `<div class="empty">aucune conversation archivée — clique « Rapatrier depuis la tablette »</div>`;
c.innerHTML = pageHead("Conversations", esc(deviceLabel(serial)) + " · archive clinique chiffrée (RGPD)",
`<button class="btn-primary" onclick="collectConversations(this,'${serial}')">Rapatrier depuis la tablette</button>`)
+ `<div class="card full">${table}</div><div id="convview"></div>`;
}
async function collectConversations(btn, serial) {
await withBusy(btn, async () => { try {
const r = await api(`/conversations/${serial}/collect`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
toast(`${r.archived} tour(s) archivé(s) sur ${r.exported} exporté(s)${r.purged ? " · fichier device purgé" : ""}`, "ok");
renderSection();
} catch (e) { toast(e.message, "err"); } });
}
async function openSession(serial, sid) {
const host = $("#convview"); host.innerHTML = `<div class="empty"><span class="spinner"></span></div>`;
let turns; try { turns = await api(`/conversations/${serial}/${sid}`); } catch (e) { host.innerHTML = `<div class="card"><div class="err">${esc(e.message)}</div></div>`; return; }
host.innerHTML = `<div class="page-head" style="margin-top:22px"><h1 style="font-size:16px">Session ${esc(sid)}</h1><div class="actions"><button class="btn-sm" onclick="exportSession('${serial}','${sid}')">Exporter JSON</button></div></div>
<div class="card full">${turns.length ? turns.map(t => { const me = t.role === "PATIENT";
return `<div style="display:flex;justify-content:${me ? "flex-start" : "flex-end"};margin:7px 0">
<div style="max-width:72%;padding:9px 13px;border-radius:12px;background:${me ? "var(--surface-2)" : "var(--primary-soft)"};border:1px solid var(--line)">
<div class="muted" style="font-size:11px;margin-bottom:2px">${me ? "Patient" : "Kazeia"} · ${fmtTs(t.ts)}</div>${esc(t.text || "")}</div></div>`; }).join("") : `<div class="empty">aucun tour</div>`}</div>`;
}
function exportSession(serial, sid) { window.open(`/api/conversations/${serial}/${sid}/export`, "_blank"); }
async function purgeSession(btn, serial, sid) {
if (!confirm(`Purger définitivement la session ${sid} de l'archive centrale ? (RGPD, irréversible)`)) return;
await withBusy(btn, async () => { try { const r = await api(`/conversations/${serial}/purge`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ session_id: sid }) }); toast(`${r.deleted} tour(s) purgé(s)`, "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } });
}
// ---- RAG thérapeutique (médical) ------------------------------------------
async function renderRag(c) {
const serial = S.selected;
if (!serial) { c.innerHTML = pageHead("RAG thérapeutique") + `<div class="card"><div class="empty"><div class="big">📚</div><p>Choisis une tablette (en haut).</p></div></div>`; return; }
const [st, docs] = await Promise.all([
api(`/rag/${serial}/status`).catch(e => ({ error: e.message })),
api(`/rag/${serial}`).catch(e => ({ error: e.message })),
]);
const statusCard = card("État du corpus", st.error ? `<div class="err">${esc(st.error)}</div>` :
`<div class="kv"><span class="k">index</span><span class="v">${dot(st.ready ? "ok" : "idle")} ${st.ready ? "prêt" : "non prêt"}</span></div>` +
kv("modèle", esc(st.model || "—")) + kv("dimension", st.dim ?? "—") + kv("documents", st.doc_count ?? 0) + kv("chunks", st.chunk_count ?? 0));
const ds = Array.isArray(docs) ? docs : [];
const docsCard = card(`Corpus (${ds.length} fiches)`, docs.error ? `<div class="err">${esc(docs.error)}</div>` :
(ds.length ? `<table><tr><th>source</th><th>caractères</th><th>chunks</th><th></th></tr>
${ds.map(d => `<tr><td><b>${esc(d.source)}</b></td><td>${d.char_count ?? "—"}</td><td>${d.chunk_count ?? "—"}</td>
<td style="white-space:nowrap"><button class="btn-sm" onclick="openDoc('${serial}','${encodeURIComponent(d.source)}')">éditer</button>
<button class="btn-sm" onclick="deleteDoc(this,'${serial}','${encodeURIComponent(d.source)}')">supprimer</button></td></tr>`).join("")}
</table>` : `<div class="empty">corpus vide</div>`) +
`<div style="margin-top:12px"><button class="btn-primary" onclick="newDoc('${serial}')">+ Nouvelle fiche</button></div>`, "full");
const testCard = card("Test de retrieval", `<div style="display:flex;gap:8px"><input id="ragq" placeholder="requête (ex. « je me sens anxieux »)" style="flex:1" onkeydown="if(event.key==='Enter')ragQuery(this.nextElementSibling,'${serial}')"><button class="btn-primary" onclick="ragQuery(this,'${serial}')">Tester</button></div><div id="raghits" style="margin-top:12px"></div>`, "full");
c.innerHTML = pageHead("RAG thérapeutique", esc(deviceLabel(serial)) + " · corpus clinique") + `<div class="grid">${statusCard}${docsCard}<div id="ragedit"></div>${testCard}</div>`;
}
async function newDoc(serial) { showDocEditor(serial, "", ""); }
async function openDoc(serial, src) {
const source = decodeURIComponent(src);
try { const d = await api(`/rag/${serial}/doc/${src}`); showDocEditor(serial, d.source, d.text || ""); }
catch (e) { toast(e.message, "err"); }
}
function showDocEditor(serial, source, text) {
const host = $("#ragedit"); host.className = "card full";
host.innerHTML = `<h3>${source ? "Éditer" : "Nouvelle fiche"}</h3>
<label style="display:flex;align-items:center;gap:10px;margin-bottom:8px"><span style="width:80px;color:var(--muted)">source</span>
<input id="rsrc" value="${esc(source)}" ${source ? "readonly" : ""} placeholder="nom-de-la-fiche" style="flex:1"></label>
<textarea id="rtext" rows="10" style="width:100%;font:inherit;padding:10px;border:1px solid var(--line-strong);border-radius:8px;resize:vertical" placeholder="Contenu clinique (multiligne, ':' autorisé)…">${esc(text)}</textarea>
<div style="margin-top:10px;display:flex;gap:8px"><button class="btn-primary" onclick="saveDoc(this,'${serial}')">Enregistrer</button><button class="btn-ghost" onclick="$('#ragedit').innerHTML='';$('#ragedit').className=''">Annuler</button></div>`;
host.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
async function saveDoc(btn, serial) {
const source = ($("#rsrc").value || "").trim(), text = $("#rtext").value;
if (!source) { toast("nomme la source", "err"); return; }
await withBusy(btn, async () => { try { await api(`/rag/${serial}/doc/${encodeURIComponent(source)}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) }); toast(`fiche « ${source} » enregistrée + ré-indexée`, "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } });
}
async function deleteDoc(btn, serial, src) {
if (!confirm(`Supprimer la fiche « ${decodeURIComponent(src)} » du corpus ?`)) return;
await withBusy(btn, async () => { try { await api(`/rag/${serial}/doc/${src}`, { method: "DELETE" }); toast("fiche supprimée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } });
}
async function ragQuery(btn, serial) {
const q = $("#ragq").value.trim(); if (!q) return;
await withBusy(btn, async () => {
try {
const hits = await api(`/rag/${serial}/query`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: q }) });
$("#raghits").innerHTML = hits.length ? hits.map(h => `<div style="padding:9px 12px;border:1px solid var(--line);border-radius:8px;margin-bottom:6px;background:var(--surface-2)"><div class="muted" style="font-size:12px">${esc(h.source)} · score ${(h.score ?? 0).toFixed ? h.score.toFixed(3) : h.score}</div>${esc((h.text || "").slice(0, 220))}${(h.text || "").length > 220 ? "…" : ""}</div>`).join("") : `<div class="muted">aucun résultat</div>`;
} catch (e) { toast(e.message, "err"); }
});
}
// ---- QUESTIONNAIRES cliniques (médical) -----------------------------------
// Catalogue = niveau FLOTTE (central, indépendant d'une tablette). Assignation +
// résultats = par patient (tablette sélectionnée → profil).
const DEFAULT_SCALE = [
{ label: "Jamais", score: 0 }, { label: "Plusieurs jours", score: 1 },
{ label: "Plus de la moitié des jours", score: 2 }, { label: "Presque tous les jours", score: 3 },
];
let QB = null; // copie de travail du builder QCM
const slugify = (s) => (s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40) || "q";
async function renderQuestionnaires(c) {
const serial = S.selected;
const list = await api(`/questionnaires`).catch(e => ({ error: e.message }));
const qs = Array.isArray(list) ? list : [];
const catalog = card(`Catalogue clinique (${qs.length})`, list.error ? `<div class="err">${esc(list.error)}</div>` :
(qs.length ? `<table><tr><th>questionnaire</th><th>questions</th><th>type</th><th></th></tr>
${qs.map(q => `<tr><td><b>${esc(q.title)}</b> ${q.builtin ? badge("fourni", "primary") : ""}<div class="muted mono">${esc(q.id)}</div></td>
<td>${q.n_questions}</td><td>QCM</td>
<td style="white-space:nowrap"><button class="btn-sm" onclick="previewQuestionnaire('${esc(q.id)}')">aperçu</button>
<button class="btn-sm" onclick="editQuestionnaire('${esc(q.id)}')">${q.builtin ? "dupliquer" : "éditer"}</button>
${q.builtin ? "" : `<button class="btn-sm" onclick="delQuestionnaire(this,'${esc(q.id)}')">suppr.</button>`}</td></tr>`).join("")}
</table>` : `<div class="empty">aucun questionnaire</div>`) +
`<div style="margin-top:12px"><button class="btn-primary" onclick="newQuestionnaire()">+ Nouveau questionnaire (QCM)</button></div>`, "full");
const assignPanel = card("Assignation & résultats · par patient",
!serial ? `<div class="empty">Choisis une tablette (en haut) puis un patient pour assigner un questionnaire et consulter ses résultats.</div>`
: `<label style="display:flex;align-items:center;gap:10px"><span style="color:var(--muted)">Patient</span>
<select id="qpat" onchange="loadPatientQ('${serial}', this.value)" style="flex:1"><option value="">— choisir —</option></select></label>
<div id="qpatbox" style="margin-top:14px"></div>`, "full");
c.innerHTML = pageHead("Questionnaires cliniques", "Échelles QCM — authoring médecin, assignation par patient, scores dans le dossier")
+ `<div class="grid">${catalog}<div id="qedit"></div>${assignPanel}</div>`;
if (serial) {
const ps = await api(`/patients/${serial}`).catch(() => []);
const sel = $("#qpat");
if (sel && Array.isArray(ps)) sel.innerHTML = `<option value="">— choisir —</option>` +
ps.map(p => `<option value="${esc(p.profile_id)}">${esc(p.full_name || p.display_name || p.profile_id)}</option>`).join("");
}
}
// --- builder QCM ---
function newQuestionnaire() {
QB = { id: "", title: "", description: "", builtin: false, scale: DEFAULT_SCALE.map(x => ({ ...x })),
questions: [{ text: "" }], ranges: [{ min: 0, max: 0, label: "" }] };
showQBuilder();
}
async function editQuestionnaire(qid) {
try {
const q = await api(`/questionnaires/${encodeURIComponent(qid)}`); const d = q.definition || {};
const dup = q.builtin; // les fournis se dupliquent (non éditables)
QB = { id: dup ? "" : q.id, title: dup ? q.title + " (copie)" : q.title, description: d.description || "", builtin: false,
scale: (d.scale || DEFAULT_SCALE).map(x => ({ ...x })),
questions: (d.questions || [{ text: "" }]).map(x => ({ text: x.text || "" })),
ranges: ((d.scoring || {}).ranges || []).map(x => ({ ...x })) };
showQBuilder();
} catch (e) { toast(e.message, "err"); }
}
function qbCollect() { // DOM → QB avant toute mutation
if (!QB) return;
QB.title = ($("#qb_title") || {}).value ?? QB.title;
QB.description = ($("#qb_desc") || {}).value ?? QB.description;
QB.scale = QB.scale.map((_, i) => ({ label: ($(`#qb_sl_${i}`) || {}).value || "", score: Number(($(`#qb_ss_${i}`) || {}).value || 0) }));
QB.questions = QB.questions.map((_, i) => ({ text: ($(`#qb_q_${i}`) || {}).value || "" }));
QB.ranges = QB.ranges.map((_, i) => ({ min: Number(($(`#qb_rmin_${i}`) || {}).value || 0), max: Number(($(`#qb_rmax_${i}`) || {}).value || 0), label: ($(`#qb_rl_${i}`) || {}).value || "" }));
}
const qbMut = (fn) => { qbCollect(); fn(); showQBuilder(); };
function qbAddScale() { qbMut(() => QB.scale.push({ label: "", score: QB.scale.length })); }
function qbDelScale(i) { qbMut(() => QB.scale.splice(i, 1)); }
function qbAddQuestion() { qbMut(() => QB.questions.push({ text: "" })); }
function qbDelQuestion(i) { qbMut(() => QB.questions.splice(i, 1)); }
function qbAddRange() { qbMut(() => QB.ranges.push({ min: 0, max: 0, label: "" })); }
function qbDelRange(i) { qbMut(() => QB.ranges.splice(i, 1)); }
function qbCancel() { QB = null; $("#qedit").innerHTML = ""; $("#qedit").className = ""; }
function showQBuilder() {
const host = $("#qedit"); host.className = "card full";
const scaleRows = QB.scale.map((s, i) => `<div style="display:flex;gap:6px;margin:4px 0;align-items:center">
<input id="qb_sl_${i}" value="${esc(s.label)}" placeholder="libellé du choix" style="flex:1">
<input id="qb_ss_${i}" type="number" value="${s.score}" title="score" style="width:70px">
<button class="btn-sm btn-ghost" onclick="qbDelScale(${i})">×</button></div>`).join("");
const qRows = QB.questions.map((q, i) => `<div style="display:flex;gap:6px;margin:4px 0;align-items:center">
<span class="muted mono" style="width:24px">${i + 1}</span>
<input id="qb_q_${i}" value="${esc(q.text)}" placeholder="énoncé de la question" style="flex:1">
<button class="btn-sm btn-ghost" onclick="qbDelQuestion(${i})">×</button></div>`).join("");
const rRows = QB.ranges.map((r, i) => `<div style="display:flex;gap:6px;margin:4px 0;align-items:center">
<input id="qb_rmin_${i}" type="number" value="${r.min}" title="min" style="width:70px">
<span class="muted">→</span><input id="qb_rmax_${i}" type="number" value="${r.max}" title="max" style="width:70px">
<input id="qb_rl_${i}" value="${esc(r.label)}" placeholder="interprétation (ex. dépression modérée)" style="flex:1">
<button class="btn-sm btn-ghost" onclick="qbDelRange(${i})">×</button></div>`).join("");
const maxScore = QB.questions.length * Math.max(0, ...QB.scale.map(s => s.score || 0));
host.innerHTML = `<h3>${QB.id ? "Éditer" : "Nouveau"} questionnaire QCM</h3>
${finput("qb_title", "Titre", QB.title)}${ftext("qb_desc", "Description (consigne au patient)", QB.description)}
${subhead(`Échelle de réponse commune — score max total : ${maxScore}`)}
<div class="muted" style="font-size:12px;margin-bottom:4px">Chaque question propose ces choix (QCM à choix unique, chaque choix vaut un score).</div>
${scaleRows}<button class="btn-sm" onclick="qbAddScale()">+ choix</button>
${subhead(`Questions (${QB.questions.length})`)}${qRows}<button class="btn-sm" onclick="qbAddQuestion()">+ question</button>
${subhead("Interprétation du score total (bandes)")}${rRows}<button class="btn-sm" onclick="qbAddRange()">+ bande</button>
<div style="margin-top:14px;display:flex;gap:8px"><button class="btn-primary" onclick="saveQuestionnaire(this)">Enregistrer</button>
<button class="btn-ghost" onclick="qbCancel()">Annuler</button></div>`;
host.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
async function saveQuestionnaire(btn) {
qbCollect();
if (!QB.title.trim()) { toast("donne un titre", "err"); return; }
if (!QB.questions.filter(q => q.text.trim()).length) { toast("ajoute au moins une question", "err"); return; }
const qid = QB.id || slugify(QB.title);
const definition = { description: QB.description, type: "qcm", scale: QB.scale.filter(s => s.label.trim()),
questions: QB.questions.filter(q => q.text.trim()), scoring: { method: "sum", ranges: QB.ranges.filter(r => r.label.trim()) } };
await withBusy(btn, async () => { try {
await api(`/questionnaires/${encodeURIComponent(qid)}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ title: QB.title.trim(), definition }) });
toast(`questionnaire « ${QB.title.trim()} » enregistré`, "ok"); QB = null; renderSection();
} catch (e) { toast(e.message, "err"); } });
}
async function delQuestionnaire(btn, qid) {
if (!confirm(`Supprimer le questionnaire « ${qid} » ? (les résultats déjà enregistrés restent dans les dossiers)`)) return;
await withBusy(btn, async () => { try { const r = await api(`/questionnaires/${encodeURIComponent(qid)}`, { method: "DELETE" });
if (r.deleted) { toast("questionnaire supprimé", "ok"); renderSection(); } else toast("questionnaire fourni — non supprimable", "err"); } catch (e) { toast(e.message, "err"); } });
}
async function previewQuestionnaire(qid) {
try {
const q = await api(`/questionnaires/${encodeURIComponent(qid)}`); const d = q.definition || {};
const scale = d.scale || []; const host = $("#qedit"); host.className = "card full";
host.innerHTML = `<h3>${esc(q.title)} <span class="badge">aperçu</span></h3>
${d.description ? `<p class="muted">${esc(d.description)}</p>` : ""}
<ol style="padding-left:20px">${(d.questions || []).map(qq => `<li style="margin:6px 0">${esc(qq.text)}
<div class="muted" style="font-size:12px">${(qq.choices || scale).map(ch => `${esc(ch.label)} (${ch.score})`).join(" · ")}</div></li>`).join("")}</ol>
${(d.scoring || {}).ranges ? subhead("Interprétation") + d.scoring.ranges.map(r => `<div class="muted" style="font-size:13px">${r.min}${r.max} : <b>${esc(r.label)}</b></div>`).join("") : ""}
<div style="margin-top:12px"><button class="btn-ghost" onclick="qbCancel()">Fermer</button></div>`;
host.scrollIntoView({ behavior: "smooth", block: "nearest" });
} catch (e) { toast(e.message, "err"); }
}
// --- assignation + résultats par patient ---
async function loadPatientQ(serial, pid) {
const box = $("#qpatbox"); if (!pid) { box.innerHTML = ""; return; }
box.innerHTML = `<div class="empty"><span class="spinner"></span></div>`;
try {
const [cat, assigns, results] = await Promise.all([
api(`/questionnaires`), api(`/questionnaires/assignments?serial=${serial}&profile_id=${pid}`), api(`/questionnaires/results/${serial}/${pid}`),
]);
const titleOf = (qid) => (cat.find(x => x.id === qid) || {}).title || qid;
const assignForm = `<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin:6px 0">
<select id="qa_q">${cat.map(q => `<option value="${esc(q.id)}">${esc(q.title)}</option>`).join("")}</select>
<select id="qa_mode" onchange="$('#qa_rec').style.display=this.value==='recurring'?'inline-flex':'none'">
<option value="once">1 fois</option><option value="recurring">récurrent</option></select>
<span id="qa_rec" style="display:none;gap:6px;align-items:center">tous les <input id="qa_days" type="number" value="7" style="width:60px"> j ·
<input id="qa_count" type="number" value="4" style="width:60px" title="nombre d'occurrences (0 = illimité)"> fois</span>
<button class="btn-sm btn-primary" onclick="assignQ(this,'${serial}','${pid}')">assigner</button></div>`;
const assignList = assigns.length ? assigns.map(a => `<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border:1px solid var(--line);border-radius:8px;margin:4px 0;background:var(--surface-2)">
<span><b>${esc(titleOf(a.questionnaire_id))}</b> · ${a.frequency.mode === "recurring" ? `tous les ${a.frequency.every_days || "?"} j${a.frequency.count ? " × " + a.frequency.count : ""}` : "1 fois"}</span>
<button class="btn-sm btn-ghost" onclick="deactivateQ(this,'${esc(a.id)}','${serial}','${pid}')">retirer</button></div>`).join("") : `<div class="muted">aucune assignation active</div>`;
const resList = results.length ? `<table><tr><th>questionnaire</th><th>date</th><th>score</th><th>interprétation</th></tr>
${results.map(r => `<tr><td>${esc(titleOf(r.questionnaire_id))}</td><td class="muted">${fmtTs(r.completed_at)}</td>
<td><b>${r.result.total_score ?? "—"}</b></td><td>${r.result.interpretation ? badge(r.result.interpretation, "primary") : "—"}</td></tr>`).join("")}
</table>` : `<div class="muted">aucun résultat enregistré</div>`;
box.innerHTML = `${subhead("Assigner")}${assignForm}${subhead("Assignations actives")}${assignList}${subhead("Résultats (dossier patient)")}${resList}`;
} catch (e) { box.innerHTML = `<div class="err">${esc(e.message)}</div>`; }
}
async function assignQ(btn, serial, pid) {
const mode = $("#qa_mode").value;
const frequency = mode === "recurring"
? { mode, every_days: Number($("#qa_days").value || 7), count: Number($("#qa_count").value || 0) }
: { mode: "once" };
const qid = $("#qa_q").value;
await withBusy(btn, async () => { try {
await api(`/questionnaires/${encodeURIComponent(qid)}/assign`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ serial, profile_id: pid, frequency }) });
toast("questionnaire assigné", "ok"); loadPatientQ(serial, pid);
} catch (e) { toast(e.message, "err"); } });
}
async function deactivateQ(btn, aid, serial, pid) {
await withBusy(btn, async () => { try { await api(`/questionnaires/assignments/${aid}/deactivate`, { method: "POST" }); toast("assignation retirée", "ok"); loadPatientQ(serial, pid); } catch (e) { toast(e.message, "err"); } });
}
// ---- store ----------------------------------------------------------------
async function lockStore() { try { await api("/lock", { method: "POST" }); S.unlocked = false; renderGate(); } catch (e) { toast(e.message, "err"); } }