longevity: modulo check-up con sezioni e alternative del manuale (#13)

strutturaCheckup in gestionale.ts (ordine Pre-test..Strumenti, sezioni ad
alternative con soglia testuale, «Altre misure» per i test dismessi in
correzione, pressione/SpO2 esclusi); checkup.astro con radio button e
fieldset (niente <select>); API checkup.ts scarta i campi delle
alternative non scelte e aggiunge una voce di giornale dalla nota
operatore sul check-up nuovo.

Refs #13
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 22:54:26 +02:00
parent cffb0a624d
commit c8187ce4db
4 changed files with 322 additions and 9 deletions
@@ -2,7 +2,11 @@ import type { APIRoute } from 'astro';
import { getLongevityDb } from '../../../../lib/longevity/db';
import { etaDelCliente } from '../../../../lib/longevity/anagrafica';
import { testAttivi, type VoceRegistro } from '../../../../lib/longevity/registro';
import { salvaCheckup, correggiCheckup } from '../../../../lib/longevity/gestionale';
import {
salvaCheckup, correggiCheckup, strutturaCheckup, testIdScartatiDalleAlternative,
type SezioneCheckup,
} from '../../../../lib/longevity/gestionale';
import { aggiungiVoce } from '../../../../lib/longevity/giornale';
import type { MisuraIn } from '../../../../lib/longevity/misure';
export const prerender = false;
@@ -52,6 +56,22 @@ export function misureDalForm(
return { ok: true, misure };
}
/**
* Le scelte del trainer nelle sezioni ad alternative del modulo (#13): legge il radio
* `alt_<chiave sezione>` dal form. Una sezione senza radio nel form (JS disattivato che non
* ha ancora impostato nulla, o un refuso) resta assente dalla mappa — la sceglie
* `testIdScartatiDalleAlternative` per default sulla prima alternativa dell'elenco.
*/
function sceltePerAlternative(sezioni: SezioneCheckup[], form: FormData): Map<string, string> {
const scelte = new Map<string, string>();
for (const s of sezioni) {
if (s.tipo !== 'alternative') continue;
const v = form.get(`alt_${s.chiave}`);
if (typeof v === 'string' && v !== '') scelte.set(s.chiave, v);
}
return scelte;
}
export const POST: APIRoute = async ({ request, locals }) => {
const form = await request.formData();
const clientCodeForm = String(form.get('client_code') ?? '').trim();
@@ -105,22 +125,36 @@ export const POST: APIRoute = async ({ request, locals }) => {
const parse = misureDalForm(form, campiAttivi);
if (!parse.ok) return erroreAllaScheda(parse.errore);
// Sezioni ad alternative (#13): si tengono solo i campi dell'alternativa scelta dalla
// radio, i compilati di altre alternative della stessa sezione si scartano — anche se il
// trainer li ha riempiti (JS disattivato, o un fieldset lasciato visibile per errore).
const { sezioni } = strutturaCheckup(db, data);
const scartati = testIdScartatiDalleAlternative(sezioni, sceltePerAlternative(sezioni, form));
const misure = parse.misure.filter((m) => !scartati.has(m.test_id));
// L'operatore è SEMPRE quello della sessione loggata (garantita da RULES + middleware:
// solo admin/trainer arrivano qui), mai un campo del form — lo stesso principio del
// client_code in pages/api/longevity/questionario.ts, applicato al campo che qui conta.
const operatore = locals.user!.username;
const eta = etaDelCliente(clientCode, data);
// Nota operatore (#13, #16): il form la mostra solo per un check-up nuovo (checkup.astro),
// quindi in correzione la chiave è assente e questo resta vuoto — nessuna voce si aggiunge.
const nota = String(form.get('nota') ?? '').trim();
if (sessioneId !== null) {
const esito = correggiCheckup(db, { sessioneId, data, eta, operatore, misure: parse.misure });
const esito = correggiCheckup(db, { sessioneId, data, eta, operatore, misure });
if (!esito.ok) return erroreAllaScheda(esito.errore);
const params = new URLSearchParams({ corretto: String(esito.sessioneId) });
if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length));
return redirect(`/longevity/gestionale/cliente/${clientCode}?${params.toString()}`);
}
const esito = salvaCheckup(db, { client_code: clientCode, data, eta, operatore, misure: parse.misure });
const esito = salvaCheckup(db, { client_code: clientCode, data, eta, operatore, misure });
if (!esito.ok) return erroreAllaScheda(esito.errore);
// Voce di giornale SOLO dopo il salvataggio riuscito (#13): se la nota fosse rifiutata
// (troppo lunga, data futura — non dovrebbe succedere, stessa data già validata sopra)
// il check-up resta comunque salvato, non si perde per un problema della nota.
if (nota !== '') aggiungiVoce(db, { clientCode, data, autore: operatore, testo: nota });
const params = new URLSearchParams({ salvato: String(esito.sessioneId) });
if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length));
@@ -9,7 +9,9 @@
import Longevity from '../../../../../layouts/Longevity.astro';
import { getLongevityDb } from '../../../../../lib/longevity/db';
import { clienteDaCodice } from '../../../../../lib/longevity/anagrafica';
import { campiDelCheckup, misureDellaSessione } from '../../../../../lib/longevity/gestionale';
import {
misureDellaSessione, strutturaCheckup, alternativaSelezionata, valoriUltimoCheckup,
} from '../../../../../lib/longevity/gestionale';
export const prerender = false;
@@ -30,8 +32,13 @@ if (sessioneId !== null && (!sessione || sessione.client_code !== cliente.client
}
const dataForm = sessione?.data ?? oggi;
const gruppi = campiDelCheckup(db, dataForm);
const modulo = strutturaCheckup(db, dataForm);
const valori = new Map(sessione?.misure.map((m) => [m.test_id, m.valore_num]) ?? []);
// Preselezione (#13): in correzione le misure della sessione, in un check-up nuovo l'ultimo
// check-up del cliente — altrimenti la prima alternativa (il livello neofita).
const valoriPreselezione = sessione
? new Map([...valori.entries()].filter((kv): kv is [string, number] => kv[1] !== null))
: valoriUltimoCheckup(db, cliente.client_code);
const errore = Astro.url.searchParams.get('errore');
---
<Longevity
@@ -55,17 +62,73 @@ const errore = Astro.url.searchParams.get('errore');
<input type="date" name="data" value={dataForm} required />
</label>
{gruppi.map((g) => (
{modulo.sezioni.map((s) => (
<section class="lg-form__blocco">
<h2>{g.asse}</h2>
{g.campi.map((c) => (
<h2>{s.titolo}</h2>
{s.tipo === 'campi' ? s.campi.map((c) => (
<label class="lg-field">
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
</label>
)) : (
<div class="lg-form__alternative" data-alt-sezione={s.chiave}>
{s.alternative.map((alt) => (
<fieldset class="lg-form__alternativa" data-alt-fieldset={alt.chiave}>
<legend>
<label>
<input
type="radio" name={`alt_${s.chiave}`} value={alt.chiave}
checked={alt.chiave === alternativaSelezionata(s, valoriPreselezione)}
/>
{alt.nome}
</label>
</legend>
{alt.soglia && <p class="lg-form__soglia">{alt.soglia}</p>}
{alt.campi.map((c) => (
<label class="lg-field">
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
</label>
))}
{alt.dettagli && alt.dettagli.length > 0 && (
<details>
<summary>Frequenza cardiaca per step (7,5-14,5 km/h)</summary>
{alt.dettagli.map((c) => (
<label class="lg-field">
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
</label>
))}
</details>
)}
</fieldset>
))}
</div>
)}
</section>
))}
{modulo.altreMisure.length > 0 && (
<section class="lg-form__blocco">
<h2>Altre misure</h2>
{modulo.altreMisure.map((c) => (
<label class="lg-field">
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
</label>
))}
</section>
))}
)}
{!sessione && (
<section class="lg-form__blocco">
<h2>Note operatore</h2>
<label class="lg-field">
<span>Nota per il giornale di bordo (facoltativa)</span>
<textarea name="nota" rows="4"></textarea>
</label>
</section>
)}
<div class="lg-form__nav">
<a href={`/longevity/gestionale/cliente/${cliente.client_code}`}>Annulla</a>
@@ -73,3 +136,19 @@ const errore = Astro.url.searchParams.get('errore');
</div>
</form>
</Longevity>
<script>
// Nasconde i fieldset delle alternative non scelte (#13): senza JS restano tutti visibili
// e il form funziona lo stesso — lo scarto vero è server-side (checkup.ts), questo è solo
// per non far compilare al trainer due protocolli della stessa sotto-metrica per sbaglio.
document.querySelectorAll<HTMLElement>('[data-alt-sezione]').forEach((sezioneEl) => {
const aggiorna = () => {
const scelta = sezioneEl.querySelector<HTMLInputElement>('input[type=radio]:checked')?.value;
sezioneEl.querySelectorAll<HTMLElement>('[data-alt-fieldset]').forEach((fs) => {
fs.hidden = fs.dataset.altFieldset !== scelta;
});
};
sezioneEl.addEventListener('change', aggiorna);
aggiorna();
});
</script>