Corrigé (audit) : - cutoffHours : garde hasModel pour ESE (parité Kotlin) — un backup importé ESE + ester non couvert levait TypeError → écran vide - min/max en une passe : les spreads Math.min(...allPoints) levaient RangeError au-delà de ~1e5 arguments (Nuage × zoom max) et allouaient par frame - init MCMC non bloquante : data-ready garantit désormais « asset chargé » Perf (portage v1.8.2 Android, fin de la dérive de miroir) : - prepareE2Context/e2AtCtx : doses groupées, cutoffs précalculés, Bateman paresseux ; computeCurve ne recalcule plus cutoffHours par point - résultat identique (175 tests verts) E2E : version lue via data-version (anti-bug v1.9.3) Docs/commentaires : pk-engine (ESE analytique), pk-calibration (JSDoc arrondi), dialogs (ESE=6), lab-timing (import mort, JSDoc orpheline), home (COLORS), pk-profile-store (test-only), pk_profiles.json exclu du zip (export-ignore, −548 Ko)
245 lines
9.3 KiB
JavaScript
245 lines
9.3 KiB
JavaScript
/**
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* Dialogs de saisie (portage de `ui/components/DoseDialog.kt` et
|
|
* `LabDialog.kt`).
|
|
*
|
|
* DoseDialog :
|
|
* - create (traitements ACTIFS proposés) / edit (TOUS les traitements —
|
|
* la dose éditée reste rattachable à son traitement même inactivé, §6.bis) ;
|
|
* - override d'ESTER par injection (comme le tableur d'origine) : la liste
|
|
* suit le MODÈLE du traitement via choicesForModel (v1.9.4 : TFS=7,
|
|
* WHSAH=6, ESE analytique=6).
|
|
*
|
|
* LabDialog (v1.2.2) : prise de sang E2 + T en UNE entrée (chacune
|
|
* optionnelle) → 1 ou 2 LabResult au même timestamp ; unités T suggérées.
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
*/
|
|
|
|
import { choicesForModel, esterLabel, isInjectionRoute } from '../data/models.js';
|
|
import { store } from '../data/store.js';
|
|
import { t } from '../util/i18n.js';
|
|
import { formatDose } from '../util/format.js';
|
|
import { el, showDialog, field, dateTimeField } from './components.js';
|
|
|
|
/**
|
|
* Dialog dose (create ou edit).
|
|
*
|
|
* @param {object} opts
|
|
* treatments : liste proposée (actifs en création, tous en édition)
|
|
* preselected : Treatment présélectionné (chips accueil / rappel) ou null
|
|
* existing : DoseLog en édition, ou null (création)
|
|
*/
|
|
export function openDoseDialog({ treatments, preselected = null, existing = null }) {
|
|
const isEdit = !!existing;
|
|
|
|
if (treatments.length === 0) {
|
|
const dlgEmpty = showDialog({
|
|
title: t('add_dose'),
|
|
body: el('p', {}, t('no_treatment_hint')),
|
|
actions: [{ label: t('ok'), className: 'btn', onClick: () => dlgEmpty.close() }],
|
|
});
|
|
return;
|
|
}
|
|
|
|
// État du dialog
|
|
let selectedId = existing
|
|
? existing.treatmentId
|
|
: (preselected && treatments.some((x) => x.id === preselected.id)
|
|
? preselected.id
|
|
: (treatments[0] ? treatments[0].id : null));
|
|
const selected = () => treatments.find((x) => x.id === selectedId) || null;
|
|
let esterOverride = existing ? existing.esterType : null;
|
|
|
|
// ── construction du corps ────────────────────────────────────────────────
|
|
const body = el('div', {});
|
|
|
|
const treatmentSelect = field({
|
|
label: t('treatment_name'),
|
|
value: selectedId ? String(selectedId) : '',
|
|
options: treatments.map((tr) => ({ value: String(tr.id), label: `${tr.name} · ${formatDose(tr.doseAmount)} ${tr.doseUnit}` })),
|
|
onInput: (v) => {
|
|
selectedId = Number(v);
|
|
esterOverride = null; // miroir : changer de traitement réinitialise l'override
|
|
rebuildEster();
|
|
amount.input.value = formatDose(selected().doseAmount);
|
|
},
|
|
});
|
|
body.appendChild(treatmentSelect);
|
|
|
|
// Override d'ester (seulement si le traitement a un PROFIL PK)
|
|
const esterWrap = el('div', {});
|
|
body.appendChild(esterWrap);
|
|
|
|
function rebuildEster() {
|
|
esterWrap.textContent = '';
|
|
const tr = selected();
|
|
if (!tr) return;
|
|
// Propriété dérivée (miroir treatmentFlags) : injection + ester ≠ NONE
|
|
const usesProfileModel = isInjectionRoute(tr.route) && tr.esterType !== 'NONE';
|
|
if (!usesProfileModel) return;
|
|
const choices = [
|
|
{ value: '', label: t('ester_default', tr.esterType) },
|
|
...choicesForModel(tr.pkModel).map((e) => ({ value: e, label: esterLabel(e) })),
|
|
];
|
|
esterWrap.appendChild(field({
|
|
label: t('ester'),
|
|
value: esterOverride || '',
|
|
options: choices,
|
|
onInput: (v) => { esterOverride = v === '' ? null : v; },
|
|
}));
|
|
}
|
|
|
|
const amount = field({
|
|
label: `${t('dose_amount')} (${selected() ? selected().doseUnit : ''})`,
|
|
type: 'number',
|
|
value: formatDose(existing ? existing.doseAmount : (selected() ? selected().doseAmount : 0)),
|
|
attrs: { step: 'any', min: '0', inputmode: 'decimal' },
|
|
});
|
|
body.appendChild(amount);
|
|
|
|
const dt = dateTimeField(t('dose_time'), existing ? existing.timestamp : Date.now(), () => {});
|
|
body.appendChild(dt);
|
|
|
|
const notes = field({ label: t('dose_notes'), type: 'textarea', value: existing ? existing.notes || '' : '' });
|
|
body.appendChild(notes);
|
|
|
|
rebuildEster();
|
|
|
|
const dlg = showDialog({
|
|
title: isEdit ? t('edit_dose') : t('add_dose'),
|
|
body,
|
|
actions: [
|
|
{ label: t('cancel'), className: 'btn text', onClick: () => dlg.close() },
|
|
{
|
|
label: t('save'),
|
|
className: 'btn',
|
|
onClick: () => {
|
|
const tr = selected();
|
|
const value = Number(String(amount.input.value).replace(',', '.'));
|
|
const ts = dt.getMs();
|
|
if (!tr || Number.isNaN(value) || value <= 0 || ts === null) return; // garde de saisie
|
|
const data = {
|
|
treatmentId: tr.id,
|
|
timestamp: ts,
|
|
doseAmount: value,
|
|
notes: notes.input.value.trim() === '' ? null : notes.input.value,
|
|
esterType: esterOverride,
|
|
};
|
|
if (isEdit) {
|
|
store.upsertDoseLog({ ...existing, ...data });
|
|
} else {
|
|
store.upsertDoseLog(data);
|
|
}
|
|
dlg.close();
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Dialog lab : prise de sang E2 + T en UNE entrée (chacune optionnelle).
|
|
*
|
|
* @param {object} opts { existing: LabResult|null }
|
|
* - create : deux sections optionnelles → 1 ou 2 LabResult au même timestamp ;
|
|
* - edit : une seule entrée (le regroupement E2+T reste intact).
|
|
*/
|
|
export function openLabDialog({ existing = null }) {
|
|
const isEdit = !!existing;
|
|
|
|
const body = el('div', {});
|
|
const dt = dateTimeField(t('lab_timestamp'), existing ? existing.timestamp : Date.now(), () => {});
|
|
body.appendChild(dt);
|
|
|
|
// Suggestions d'unités T (miroir LabDialog Android) : ng/mL, ng/dL, ng/L,
|
|
// nmol/L — la conversion vers ng/mL se fait à la calibration et au rendu.
|
|
const UNIT_OPTIONS = ['pg/mL', 'ng/mL', 'ng/dL', 'ng/L', 'nmol/L', 'mIU/L'];
|
|
|
|
let e2Input = null;
|
|
let e2Notes = null;
|
|
let tInput = null;
|
|
let tUnit = null;
|
|
|
|
if (isEdit) {
|
|
const isT = String(existing.marker).toUpperCase() === 'T';
|
|
const valueField = field({
|
|
label: isT ? t('lab_t_value') : t('lab_e2_value'),
|
|
type: 'number',
|
|
value: String(existing.value),
|
|
attrs: { step: 'any', inputmode: 'decimal' },
|
|
});
|
|
body.appendChild(valueField);
|
|
if (isT) {
|
|
tInput = valueField.input;
|
|
const unitSel = field({
|
|
label: t('dose_unit'),
|
|
value: existing.unit,
|
|
options: UNIT_OPTIONS.map((u) => ({ value: u, label: u })),
|
|
});
|
|
body.appendChild(unitSel);
|
|
tUnit = unitSel.input;
|
|
} else {
|
|
e2Input = valueField.input;
|
|
}
|
|
} else {
|
|
// Section E2 (optionnelle) — unité fixée pg/mL (marker E2 = estradiol)
|
|
const e2Field = field({ label: `${t('lab_e2_value')} (${t('lab_optional')})`, type: 'number', attrs: { step: 'any', inputmode: 'decimal' } });
|
|
body.appendChild(e2Field);
|
|
e2Input = e2Field.input;
|
|
|
|
// Section T (optionnelle) avec choix d'unité
|
|
const tField = field({ label: `${t('lab_t_value')} (${t('lab_optional')})`, type: 'number', attrs: { step: 'any', inputmode: 'decimal' } });
|
|
body.appendChild(tField);
|
|
tInput = tField.input;
|
|
const unitSel = field({
|
|
label: `${t('dose_unit')} — ${t('lab_units_hint')}`,
|
|
value: 'ng/mL',
|
|
options: UNIT_OPTIONS.map((u) => ({ value: u, label: u })),
|
|
});
|
|
body.appendChild(unitSel);
|
|
tUnit = unitSel.input;
|
|
}
|
|
|
|
const notes = field({ label: t('lab_notes'), type: 'textarea', value: existing ? existing.notes || '' : '' });
|
|
body.appendChild(notes);
|
|
|
|
const dlg = showDialog({
|
|
title: isEdit ? t('edit_which_lab') : t('add_lab'),
|
|
body,
|
|
actions: [
|
|
{ label: t('cancel'), className: 'btn text', onClick: () => dlg.close() },
|
|
{
|
|
label: t('save'),
|
|
className: 'btn',
|
|
onClick: () => {
|
|
const ts = dt.getMs();
|
|
if (ts === null) return;
|
|
const notesVal = notes.input.value.trim() === '' ? null : notes.input.value;
|
|
if (isEdit) {
|
|
const value = Number(String((tInput || e2Input).value).replace(',', '.'));
|
|
if (Number.isNaN(value)) return;
|
|
store.upsertLabResult({
|
|
...existing,
|
|
value,
|
|
unit: tInput ? tUnit.value : existing.unit,
|
|
timestamp: ts,
|
|
notes: notesVal,
|
|
});
|
|
} else {
|
|
const e2Val = String(e2Input.value).trim() === '' ? null : Number(e2Input.value.replace(',', '.'));
|
|
const tVal = String(tInput.value).trim() === '' ? null : Number(tInput.value.replace(',', '.'));
|
|
if (e2Val === null && tVal === null) return; // au moins une valeur
|
|
if (e2Val !== null && !Number.isNaN(e2Val)) {
|
|
store.upsertLabResult({ marker: 'E2', value: e2Val, unit: 'pg/mL', timestamp: ts, notes: notesVal });
|
|
}
|
|
if (tVal !== null && !Number.isNaN(tVal)) {
|
|
store.upsertLabResult({ marker: 'T', value: tVal, unit: tUnit.value, timestamp: ts, notes: notesVal });
|
|
}
|
|
}
|
|
dlg.close();
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|