/** * ───────────────────────────────────────────────────────────────────────────── * Éditeur de traitement (portage de `ui/screens/TreatmentEditorScreen.kt`). * * - presets (PKPresets, 25 entrées) pré-remplissent tout (création) — * v1.9.4 : le modèle ESE offre ses 6 esters injectables (EV/EU/EEn/EC/ * EB/EUCS), au même niveau que les presets TFS (7) et WHSAH (6) ; * - champs conditionnels : ester + modèle PK si injection, Bateman sinon — * v1.9.4 : la liste des esters suit la couverture RÉELLE du modèle * choisi (choicesForModel) et rebascule sur EV si l'ester courant * n'y est plus (garde anti « 0 pg/mL ») ; * - carte Calibration : facteur manuel + bouton « Calibrer avec les * analyses » (computeScaleFactor — médiane lab ÷ prédiction, garde #61) ; * hint quand l'auto-calibration écrase le facteur à l'affichage (v1.4.9) ; * - section Posologie (prévision, intervalle en jours) ; * - carte Rappel : switch + heure → notifications navigateur (web) ; * - section Agenda : AFFICHAGE informatif seulement (impossible dans un * navigateur — pas de CalendarProvider) ; * - switch Actif : drapeau administratif (§6.bis) — ne retire rien de la * simulation ; * - save → insert/update ; delete avec confirmation. * * ⚠️ Miroir du bug #51 (Android) : la reconstruction d'une entité pour un * save doit repartir des champs NON ÉDITABLES — ici `createdAt` chargé et * conservé (pas de calendarEventId côté web : toujours null). * ───────────────────────────────────────────────────────────────────────────── */ import { store } from '../data/store.js'; import { newTreatment, isInjectionRoute, typeLabelKey, routeLabelKey, modelLabelKey, choicesForModel } from '../data/models.js'; import { all as allPresets } from '../pk/presets.js'; import { computeScaleFactor } from '../pk/pk-calibration.js'; import { t } from '../util/i18n.js'; import { formatDose } from '../util/format.js'; import { el, showDialog, field, dropdown, switchRow, confirmDialog } from './components.js'; /** Parse un champ numérique FR (virgule → point) ; défaut si vide/invalide. */ function parseNum(text, fallback) { const v = Number(String(text).replace(',', '.')); return Number.isFinite(v) ? v : fallback; } /** * Rend l'éditeur. * @param {HTMLElement} container * @param {object} ctx { treatmentId: number (0 = nouveau), onDone: ()=>void } */ export function renderTreatmentEditor(container, ctx) { const isNew = ctx.treatmentId <= 0; const existing = isNew ? null : store.getTreatments().find((x) => x.id === ctx.treatmentId) || null; // ── État du formulaire (pré-rempli depuis le traitement ou défauts) ────── const st = { name: existing ? existing.name : '', type: existing ? existing.type : 'ESTRADIOL', route: existing ? existing.route : 'INJECTION_IM', ester: existing ? existing.esterType : 'EV', model: existing ? existing.pkModel : 'ESE', doseText: existing ? formatDose(existing.doseAmount) : '4', unit: existing ? existing.doseUnit : 'mg', tmaxText: existing ? formatDose(existing.absorptionHours) : '46', thalfText: existing ? formatDose(existing.eliminationHalfLifeHours) : '100', bioText: existing ? String(existing.bioavailabilityFraction) : '1', scaleText: existing ? formatDose(existing.scaleFactor) : '1', reminderEnabled: existing ? existing.reminderEnabled : false, reminderHour: existing ? (existing.reminderHour ?? 12) : 12, reminderMinute: existing ? (existing.reminderMinute ?? 0) : 0, active: existing ? existing.isActive : true, forecastEnabled: existing ? existing.forecastIntervalDays !== null && existing.forecastIntervalDays !== undefined : false, forecastDaysText: existing && existing.forecastIntervalDays !== null ? formatDose(existing.forecastIntervalDays) : '', // ⚠️ champs NON ÉDITABLES conservés (bug #51 côté Android) : createdAt: existing ? existing.createdAt : Date.now(), }; const settings = store.getSettings(); const col = el('div', {}); container.appendChild(col); // Titre + retour const header = el('div', { class: 'row' }, [ el('button', { class: 'btn text', onclick: ctx.onDone }, '←'), el('h1', { style: 'font-size:1.15rem;' }, isNew ? t('add_treatment') : t('edit_treatment')), ]); col.appendChild(header); // ── Presets (création seulement) ───────────────────────────────────────── if (isNew) { const presetOptions = [{ value: '', label: t('select_preset') }, ...allPresets.map((p, i) => ({ value: String(i), label: t(p.nameKey) }))]; col.appendChild(dropdown(t('select_preset'), '', presetOptions, (v) => { if (v === '') return; const p = allPresets[Number(v)]; st.name = t(p.nameKey); st.type = p.type; st.route = p.route; st.ester = p.esterType; st.model = p.pkModel; st.doseText = formatDose(p.defaultDoseAmount); st.unit = p.defaultDoseUnit; st.tmaxText = formatDose(p.absorptionHours); st.thalfText = formatDose(p.eliminationHalfLifeHours); st.bioText = String(p.bioavailabilityFraction); rerenderForm(); })); } const form = el('div', {}); col.appendChild(form); /** Reconstruit le formulaire (champs conditionnels route/type). */ function rerenderForm() { form.textContent = ''; const nameField = field({ label: t('treatment_name'), value: st.name, onInput: (v) => { st.name = v; } }); form.appendChild(nameField); const typeOptions = ['ESTRADIOL', 'ANTI_ANDROGEN', 'PROGESTOGEN', 'OTHER'] .map((x) => ({ value: x, label: t(typeLabelKey(x)) })); form.appendChild(dropdown(t('treatment_type'), st.type, typeOptions, (v) => { st.type = v; rerenderForm(); // la carte calibration ne concerne qu'E2 })); const routeOptions = Object.keys({ ORAL: 1, TRANSDERMAL_GEL: 1, TRANSDERMAL_PATCH: 1, INJECTION_IM: 1, INJECTION_SUBCUT: 1, OTHER: 1, }).map((x) => ({ value: x, label: t(routeLabelKey(x)) })); form.appendChild(dropdown(t('treatment_route'), st.route, routeOptions, (v) => { st.route = v; rerenderForm(); // champs ester/modèle vs Bateman })); if (isInjectionRoute(st.route)) { // Ester : liste = couverture RÉELLE du modèle choisi (v1.9.4 — // miroir Android Esters.choicesForModel) : ESE offre ses 6 esters // injectables depuis l'éditeur, au lieu des seuls EV/EU/EEn de // l'époque des tables ODS. L'override par dose du dialog garde le // même helper (pas de divergence des deux listes). const esterOptions = choicesForModel(st.model).map((x) => ({ value: x, label: x })); form.appendChild(dropdown(t('ester'), st.ester, esterOptions, (v) => { st.ester = v; })); // Modèle PK : 3 choix (v1.4.6 — WHSAH ajouté). Au changement de // modèle, l'ester courant peut sortir de la couverture (ex. EUCS + // TFS : le V3C n'a pas de paramètres EUCS → 0 pg/mL) : on rebascule // sur le premier ester couvert (EV partout) puis on re-rend le // formulaire pour reconstruire la liste des esters (v1.9.4). const modelOptions = ['ESE', 'TFS', 'WHS'].map((x) => ({ value: x, label: t(modelLabelKey(x)) })); form.appendChild(dropdown(t('pk_model'), st.model, modelOptions, (v) => { st.model = v; if (!choicesForModel(v).includes(st.ester)) st.ester = choicesForModel(v)[0]; rerenderForm(); })); } else { const row1 = el('div', { class: 'row' }); row1.appendChild(field({ label: t('pk_absorption'), type: 'number', value: st.tmaxText, onInput: (v) => { st.tmaxText = v; }, attrs: { step: 'any' } })); row1.appendChild(field({ label: t('pk_halflife'), type: 'number', value: st.thalfText, onInput: (v) => { st.thalfText = v; }, attrs: { step: 'any' } })); form.appendChild(row1); form.appendChild(field({ label: t('pk_bioavail'), type: 'number', value: st.bioText, onInput: (v) => { st.bioText = v; }, attrs: { step: 'any', min: '0.01', max: '1' } })); } const doseRow = el('div', { class: 'row' }); doseRow.appendChild(field({ label: t('default_dose'), type: 'number', value: st.doseText, onInput: (v) => { st.doseText = v; }, attrs: { step: 'any', inputmode: 'decimal' } })); doseRow.appendChild(field({ label: t('dose_unit'), value: st.unit, onInput: (v) => { st.unit = v; } })); form.appendChild(doseRow); // ── Calibration (E2 seulement — miroir du if type == ESTRADIOL) ──────── if (st.type === 'ESTRADIOL') { const calCard = el('div', { class: 'card' }); calCard.appendChild(el('h3', {}, t('calibration_title'))); calCard.appendChild(field({ label: t('scale_factor'), type: 'number', value: st.scaleText, onInput: (v) => { st.scaleText = v; }, attrs: { step: 'any', inputmode: 'decimal' } })); calCard.appendChild(el('div', { class: 'hint' }, t('calibration_hint'))); if (settings.autoCalibrate) { // v1.4.9 : quand l'auto est ON, le facteur manuel est IGNORÉ à // l'affichage des courbes — l'expliciter (confusion « j'ai calibré // mais rien ne change ») calCard.appendChild(el('div', { class: 'hint', style: 'color:var(--teal);' }, t('calibration_auto_override'))); } calCard.appendChild(el('button', { class: 'btn tonal', onclick: () => { const tmp = buildTreatment(existing ? existing.id : 0, { scaleFactor: 1.0 }); if (!tmp) return; const doses = store.getDoseLogs(); const labs = store.getLabResults().filter((l) => String(l.marker).toUpperCase() === 'E2'); const sf = computeScaleFactor(tmp, doses, labs); if (sf !== null) { st.scaleText = sf.toFixed(2); rerenderForm(); } }, }, t('calibrate_from_labs'))); form.appendChild(calCard); } // ── Posologie (prévision) ─────────────────────────────────────────────── const freqCard = el('div', { class: 'card' }); freqCard.appendChild(switchRow(t('freq_section'), st.forecastEnabled, (v) => { st.forecastEnabled = v; rerenderForm(); })); if (st.forecastEnabled) { freqCard.appendChild(field({ label: t('freq_interval'), type: 'number', value: st.forecastDaysText, onInput: (v) => { st.forecastDaysText = v; }, attrs: { step: 'any', inputmode: 'decimal', min: '0' } })); freqCard.appendChild(el('div', { class: 'hint' }, t('freq_hint'))); } form.appendChild(freqCard); // ── Rappel (notifications navigateur — cf ui/reminders.js) ────────────── const remCard = el('div', { class: 'card' }); remCard.appendChild(switchRow(t('reminder'), st.reminderEnabled, (v) => { st.reminderEnabled = v; rerenderForm(); })); if (st.reminderEnabled) { remCard.appendChild(field({ label: t('reminder_time'), type: 'time', value: `${String(st.reminderHour).padStart(2, '0')}:${String(st.reminderMinute).padStart(2, '0')}`, onInput: (v) => { const [h, m] = v.split(':').map(Number); if (!Number.isNaN(h) && !Number.isNaN(m)) { st.reminderHour = h; st.reminderMinute = m; } }, })); } form.appendChild(remCard); // ── Agenda : AFFICHAGE informatif (non portable dans un navigateur) ───── const agendaCard = el('div', { class: 'card' }); agendaCard.appendChild(el('h3', {}, t('agenda_section'))); agendaCard.appendChild(el('div', { class: 'hint' }, t('agenda_hint'))); form.appendChild(agendaCard); // ── Actif (drapeau administratif — §6.bis) ────────────────────────────── form.appendChild(switchRow(t('active'), st.active, (v) => { st.active = v; })); // ── Save ──────────────────────────────────────────────────────────────── form.appendChild(el('button', { class: 'btn', style: 'width:100%;margin-top:10px;', onclick: () => { const tr = buildTreatment(existing ? existing.id : 0, null); if (!tr) return; // validation (nom + dose > 0) store.upsertTreatment(tr); ctx.onDone(); }, }, t('save'))); // ── Delete (édition seulement) ────────────────────────────────────────── if (!isNew) { form.appendChild(el('button', { class: 'btn text danger', style: 'width:100%;margin-top:6px;', onclick: () => confirmDialog( t('delete'), t('confirm_delete_treatment'), () => { store.deleteTreatment(existing.id); ctx.onDone(); }, ), }, t('delete'))); } } /** * Construit le Treatment final depuis l'état du formulaire (miroir * buildTreatment — validations : nom non vide, dose > 0 ; bio ∈ [0.01, 1] ; * scale ≥ 0.01 ; prévision si activée avec intervalle > 0 ; ester NONE * hors injection ; createdAt/(calendarEventId) conservés). */ function buildTreatment(id, overrides) { const dose = parseNum(st.doseText, NaN); if (st.name.trim() === '' || Number.isNaN(dose) || dose <= 0.0) return null; const forecastInterval = st.forecastEnabled ? (parseNum(st.forecastDaysText, 0) > 0 ? parseNum(st.forecastDaysText, 0) : null) : null; const base = newTreatment({ id, name: st.name.trim(), type: st.type, route: st.route, doseAmount: dose, doseUnit: st.unit.trim() === '' ? 'mg' : st.unit.trim(), isActive: st.active, // ester NONE hors injection (miroir du Kotlin) esterType: isInjectionRoute(st.route) ? st.ester : 'NONE', pkModel: st.model, absorptionHours: parseNum(st.tmaxText, 4), eliminationHalfLifeHours: parseNum(st.thalfText, 24), bioavailabilityFraction: Math.min(Math.max(parseNum(st.bioText, 1), 0.01), 1), scaleFactor: Math.max(parseNum(st.scaleText, 1), 0.01), forecastIntervalDays: forecastInterval, reminderEnabled: st.reminderEnabled, reminderHour: st.reminderEnabled ? st.reminderHour : null, reminderMinute: st.reminderEnabled ? st.reminderMinute : null, // ⚠️ champ NON ÉDITABLE conservé (bug #51) createdAt: st.createdAt, calendarEventId: null, // toujours null côté web }); return overrides ? { ...base, ...overrides } : base; } rerenderForm(); }