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:
@@ -56,6 +56,197 @@ export function campiDelCheckup(db: Database.Database, alla: string): GruppoChec
|
|||||||
return ordine.map((asse) => ({ asse, campi: perAsse.get(asse)! }));
|
return ordine.map((asse) => ({ asse, campi: perAsse.get(asse)! }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Struttura del modulo di check-up (#13, manuale di Nicola del 27/09/2026) --------------
|
||||||
|
// A differenza di campiDelCheckup (raggruppato per asse, invariato: lo usa ancora la pagina
|
||||||
|
// di dettaglio sessione, sessione/[id].astro, per la vista di sola lettura), qui l'ordine e
|
||||||
|
// il raggruppamento sono quelli del modulo cartaceo: Pre-test -> Mobilità -> Equilibrio ->
|
||||||
|
// Presa -> Core -> Forza spinta -> Forza trazione -> Arti inferiori -> Aerobico -> Strumenti.
|
||||||
|
// Una sezione o e' a campi fissi, o e' "ad alternative" (piu' protocolli per la stessa
|
||||||
|
// sotto-metrica, uno solo si compila: radio button, mai <select> — tests/longevity/
|
||||||
|
// referto.test.ts intercetta la parola). test_id qui sono stringhe: si risolvono contro
|
||||||
|
// testAttivi in strutturaCheckup, cosi' un test dismesso sparisce da solo dal modulo.
|
||||||
|
|
||||||
|
type AlternativaRaw = { chiave: string; nome: string; soglia: string; campi: string[]; dettagli?: string[] };
|
||||||
|
type SezioneRaw =
|
||||||
|
| { titolo: string; tipo: 'campi'; campi: string[] }
|
||||||
|
| { titolo: string; tipo: 'alternative'; chiave: string; alternative: AlternativaRaw[] };
|
||||||
|
|
||||||
|
// Pressione e SpO2 NON compaiono nel modulo (decisione di Adriano, 27/09/2026: arrivano
|
||||||
|
// dall'import Wellness Tower di #3, non da inserimento manuale) — esclusi anche da
|
||||||
|
// «Altre misure», non solo dalle sezioni: sono attivi nel registro ma il modulo non li mostra.
|
||||||
|
const ESCLUSI_DAL_MODULO = new Set(['f_pa_sistolica', 'f_pa_diastolica', 'f_spo2_pct']);
|
||||||
|
|
||||||
|
const STRUTTURA_RAW: SezioneRaw[] = [
|
||||||
|
{ titolo: 'Pre-test', tipo: 'campi', campi: ['f_fc_riposo_bpm', 'f_peso_kg', 'f_altezza_cm'] },
|
||||||
|
{ titolo: 'Mobilità', tipo: 'campi', campi: ['f_back_scratch_dx_cm', 'f_back_scratch_sx_cm', 'f_sit_reach_cm'] },
|
||||||
|
{ titolo: 'Equilibrio', tipo: 'alternative', chiave: 'equilibrio', alternative: [
|
||||||
|
{ chiave: 'occhi_aperti', nome: 'Flamingo, occhi aperti',
|
||||||
|
soglia: 'Soglia: 4-7 cadute a occhi aperti → passa a occhi chiusi.',
|
||||||
|
campi: ['f_flamingo_oa_dx', 'f_flamingo_oa_sx'] },
|
||||||
|
{ chiave: 'occhi_chiusi', nome: 'Flamingo, occhi chiusi', soglia: '',
|
||||||
|
campi: ['f_flamingo_oc_dx', 'f_flamingo_oc_sx'] },
|
||||||
|
] },
|
||||||
|
{ titolo: 'Presa', tipo: 'campi', campi: ['f_handgrip_dx_kg', 'f_handgrip_sx_kg'] },
|
||||||
|
{ titolo: 'Core', tipo: 'alternative', chiave: 'core', alternative: [
|
||||||
|
{ chiave: 'ginocchia', nome: 'Plank sulle ginocchia',
|
||||||
|
soglia: 'Soglia: ≥30-45 s sulle ginocchia → passa a plank pieno.', campi: ['f_plank_ginocchia_sec'] },
|
||||||
|
{ chiave: 'pieno', nome: 'Plank pieno', soglia: '', campi: ['f_plank_sec'] },
|
||||||
|
] },
|
||||||
|
{ titolo: 'Forza spinta', tipo: 'alternative', chiave: 'spinta', alternative: [
|
||||||
|
{ chiave: 'manubri', nome: 'Panca con manubri (5RM, kg per manubrio)',
|
||||||
|
soglia: 'Soglia: esegue ≥1 push-up corretto → prova push-up.',
|
||||||
|
campi: ['f_panca_man_carico_kg', 'f_panca_man_rip'] },
|
||||||
|
{ chiave: 'pushup', nome: 'Push-up (donne sulle ginocchia)',
|
||||||
|
soglia: 'Soglia: fascia «Buono» o oltre → passa a panca con bilanciere.', campi: ['f_pushup_rip'] },
|
||||||
|
{ chiave: 'bilanciere', nome: 'Panca con bilanciere (5RM)', soglia: '',
|
||||||
|
campi: ['f_panca_bil_carico_kg', 'f_panca_bil_rip'] },
|
||||||
|
] },
|
||||||
|
{ titolo: 'Forza trazione', tipo: 'alternative', chiave: 'trazione', alternative: [
|
||||||
|
{ chiave: 'rematore', nome: 'Rematore (5RM)',
|
||||||
|
soglia: 'Soglia: ≥1 trazione, o carico 0,95-1,19× peso corporeo → passa a trazioni.',
|
||||||
|
campi: ['f_row_carico_kg', 'f_row_rip'] },
|
||||||
|
{ chiave: 'trazioni', nome: 'Trazioni alla sbarra', soglia: '', campi: ['f_trazioni_rip'] },
|
||||||
|
] },
|
||||||
|
{ titolo: 'Arti inferiori', tipo: 'alternative', chiave: 'arti_inferiori', alternative: [
|
||||||
|
{ chiave: 'sit_to_stand', nome: 'Sit-to-stand, 1 minuto (sedia 43-45 cm)',
|
||||||
|
soglia: 'Soglia: rapporto risultato/predetto ≥1,0 → passa a squat.', campi: ['f_sit_to_stand_rip'] },
|
||||||
|
{ chiave: 'squat', nome: 'Squat (5RM)', soglia: '', campi: ['f_squat_carico_kg', 'f_squat_rip'] },
|
||||||
|
] },
|
||||||
|
{ titolo: 'Aerobico', tipo: 'alternative', chiave: 'aerobico', alternative: [
|
||||||
|
{ chiave: '6mwt', nome: '6 Minute Walk Test',
|
||||||
|
soglia: 'Soglia: ≥80% del predetto → passa a Queen\'s College o 2 km.', campi: ['f_6mwt_m'] },
|
||||||
|
{ chiave: 'queens', nome: 'Queen\'s College Step Test',
|
||||||
|
soglia: 'Soglia: superato → passa a MUTT o MILFIT.', campi: ['f_step_hr_recovery'] },
|
||||||
|
{ chiave: '2km', nome: 'Camminata 2 km (UKK)',
|
||||||
|
soglia: 'Soglia: superato → passa a MUTT o MILFIT.', campi: ['f_walk2km_min', 'f_walk2km_fc_bpm'] },
|
||||||
|
{ chiave: 'mutt', nome: 'MUTT', soglia: 'Soglia: superato → passa a Calibre (VO2max misurato).',
|
||||||
|
campi: ['f_mutt_vam', 'f_mutt_fc_max_bpm', 'f_mutt_fc_1min_bpm', 'f_mutt_fc_2min_bpm'],
|
||||||
|
dettagli: [
|
||||||
|
'f_mutt_fc_7_5', 'f_mutt_fc_8_5', 'f_mutt_fc_9_5', 'f_mutt_fc_10_5',
|
||||||
|
'f_mutt_fc_11_5', 'f_mutt_fc_12_5', 'f_mutt_fc_13_5', 'f_mutt_fc_14_5',
|
||||||
|
] },
|
||||||
|
{ chiave: 'milfit', nome: 'MILFIT',
|
||||||
|
soglia: 'Soglia: superato → passa a Calibre (VO2max misurato).', campi: ['f_milfit_watt'] },
|
||||||
|
{ chiave: 'calibre', nome: 'Calibre (VO2max misurato)', soglia: '', campi: ['f_vo2max'] },
|
||||||
|
] },
|
||||||
|
{ titolo: 'Strumenti (Wellness Tower, Stress Index)', tipo: 'campi',
|
||||||
|
campi: ['f_grasso_pct', 'f_muscolo_pct', 'f_whr', 'f_hrv_score', 'f_spalla_outreach_deg', 'f_spalla_buckling_deg'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type AlternativaCheckup = {
|
||||||
|
chiave: string; nome: string; soglia: string;
|
||||||
|
campi: CampoQuestionario[]; dettagli?: CampoQuestionario[];
|
||||||
|
};
|
||||||
|
export type SezioneCheckup =
|
||||||
|
| { titolo: string; tipo: 'campi'; campi: CampoQuestionario[] }
|
||||||
|
| { titolo: string; tipo: 'alternative'; chiave: string; alternative: AlternativaCheckup[] };
|
||||||
|
export type ModuloCheckup = { sezioni: SezioneCheckup[]; altreMisure: CampoQuestionario[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il modulo del check-up nell'ordine del manuale (#13): solo i test `f_*` attivi alla data
|
||||||
|
* indicata. Un test attivo ma assente da STRUTTURA_RAW (i dismessi, correggendo una sessione
|
||||||
|
* vecchia) finisce in `altreMisure`, mai perso — tranne pressione e SpO2 (ESCLUSI_DAL_MODULO),
|
||||||
|
* che non compaiono da nessuna parte qui: restano nel registro, li scrive l'import #3.
|
||||||
|
*/
|
||||||
|
export function strutturaCheckup(db: Database.Database, alla: string): ModuloCheckup {
|
||||||
|
const fisici = testAttivi(db, alla).filter((v) => v.test_id.startsWith('f_'));
|
||||||
|
const perId = new Map<string, CampoQuestionario>(fisici.map((v) => [v.test_id, {
|
||||||
|
id: v.test_id, etichetta: v.etichetta, tipo: v.tipo_valore,
|
||||||
|
unita: v.unita, min: v.range_min, max: v.range_max, sottoDominio: v.sotto_dominio,
|
||||||
|
}]));
|
||||||
|
|
||||||
|
const usati = new Set<string>(ESCLUSI_DAL_MODULO);
|
||||||
|
const risolvi = (ids: string[]): CampoQuestionario[] => {
|
||||||
|
const out: CampoQuestionario[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
usati.add(id);
|
||||||
|
const c = perId.get(id);
|
||||||
|
if (c) out.push(c); // non attivo alla data: si salta, non un campo vuoto
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sezioni: SezioneCheckup[] = [];
|
||||||
|
for (const s of STRUTTURA_RAW) {
|
||||||
|
if (s.tipo === 'campi') {
|
||||||
|
const campi = risolvi(s.campi);
|
||||||
|
if (campi.length > 0) sezioni.push({ titolo: s.titolo, tipo: 'campi', campi });
|
||||||
|
} else {
|
||||||
|
const alternative: AlternativaCheckup[] = [];
|
||||||
|
for (const a of s.alternative) {
|
||||||
|
const campi = risolvi(a.campi);
|
||||||
|
const dettagli = a.dettagli ? risolvi(a.dettagli) : undefined;
|
||||||
|
if (campi.length > 0 || (dettagli && dettagli.length > 0)) {
|
||||||
|
alternative.push({ chiave: a.chiave, nome: a.nome, soglia: a.soglia, campi, dettagli });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (alternative.length > 0) sezioni.push({ titolo: s.titolo, tipo: 'alternative', chiave: s.chiave, alternative });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const altreMisure = fisici.filter((v) => !usati.has(v.test_id)).map((v) => perId.get(v.test_id)!);
|
||||||
|
|
||||||
|
return { sezioni, altreMisure };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quale alternativa preselezionare in una sezione ad alternative (#13): la prima, in ordine
|
||||||
|
* di elencazione, che ha almeno uno dei suoi test_id (campi o dettagli) fra `valori` — in
|
||||||
|
* correzione `valori` sono le misure della sessione, in un check-up nuovo quelle dell'ultimo
|
||||||
|
* check-up del cliente (`valoriUltimoCheckup`). Senza corrispondenza vince la prima
|
||||||
|
* dell'elenco (il livello neofita, come dice l'analisi).
|
||||||
|
*/
|
||||||
|
export function alternativaSelezionata(
|
||||||
|
sezione: Extract<SezioneCheckup, { tipo: 'alternative' }>, valori: Map<string, number>
|
||||||
|
): string {
|
||||||
|
for (const alt of sezione.alternative) {
|
||||||
|
const ids = [...alt.campi.map((c) => c.id), ...(alt.dettagli?.map((c) => c.id) ?? [])];
|
||||||
|
if (ids.some((id) => valori.has(id))) return alt.chiave;
|
||||||
|
}
|
||||||
|
return sezione.alternative[0]?.chiave ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le misure dell'ultimo check-up del cliente (più recente prima, come `sessioniDi`), per
|
||||||
|
* precompilare `alternativaSelezionata` in un check-up NUOVO — mappa vuota se il cliente non
|
||||||
|
* ha ancora un check-up.
|
||||||
|
*/
|
||||||
|
export function valoriUltimoCheckup(db: Database.Database, clientCode: string): Map<string, number> {
|
||||||
|
const riga = db.prepare(
|
||||||
|
`SELECT id FROM sessioni WHERE client_code = ? AND tipo = 'checkup' ORDER BY data DESC, id DESC LIMIT 1`
|
||||||
|
).get(clientCode) as { id: number } | undefined;
|
||||||
|
if (!riga) return new Map();
|
||||||
|
|
||||||
|
const dettaglio = misureDellaSessione(db, riga.id);
|
||||||
|
const out = new Map<string, number>();
|
||||||
|
for (const m of dettaglio?.misure ?? []) {
|
||||||
|
if (m.valore_num !== null) out.set(m.test_id, m.valore_num);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I test_id da scartare perché appartengono a un'alternativa NON scelta di una sezione (#13):
|
||||||
|
* `scelte` mappa la chiave di sezione alla chiave dell'alternativa scelta (dalla radio nel
|
||||||
|
* form) — assente = vale la prima alternativa (comportamento del form senza JS/radio
|
||||||
|
* mancante). Una sezione a campi fissi non contribuisce: nulla da scartare lì.
|
||||||
|
*/
|
||||||
|
export function testIdScartatiDalleAlternative(
|
||||||
|
sezioni: SezioneCheckup[], scelte: Map<string, string>
|
||||||
|
): Set<string> {
|
||||||
|
const scartati = new Set<string>();
|
||||||
|
for (const s of sezioni) {
|
||||||
|
if (s.tipo !== 'alternative') continue;
|
||||||
|
const scelta = scelte.get(s.chiave) ?? s.alternative[0]?.chiave;
|
||||||
|
for (const alt of s.alternative) {
|
||||||
|
if (alt.chiave === scelta) continue;
|
||||||
|
for (const c of alt.campi) scartati.add(c.id);
|
||||||
|
for (const c of alt.dettagli ?? []) scartati.add(c.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scartati;
|
||||||
|
}
|
||||||
|
|
||||||
export type EsitoCheckup =
|
export type EsitoCheckup =
|
||||||
| { ok: true; sessioneId: number; fuoriRange: string[] }
|
| { ok: true; sessioneId: number; fuoriRange: string[] }
|
||||||
| { ok: false; errore: string };
|
| { ok: false; errore: string };
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import type { APIRoute } from 'astro';
|
|||||||
import { getLongevityDb } from '../../../../lib/longevity/db';
|
import { getLongevityDb } from '../../../../lib/longevity/db';
|
||||||
import { etaDelCliente } from '../../../../lib/longevity/anagrafica';
|
import { etaDelCliente } from '../../../../lib/longevity/anagrafica';
|
||||||
import { testAttivi, type VoceRegistro } from '../../../../lib/longevity/registro';
|
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';
|
import type { MisuraIn } from '../../../../lib/longevity/misure';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
@@ -52,6 +56,22 @@ export function misureDalForm(
|
|||||||
return { ok: true, misure };
|
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 }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const clientCodeForm = String(form.get('client_code') ?? '').trim();
|
const clientCodeForm = String(form.get('client_code') ?? '').trim();
|
||||||
@@ -105,22 +125,36 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
const parse = misureDalForm(form, campiAttivi);
|
const parse = misureDalForm(form, campiAttivi);
|
||||||
if (!parse.ok) return erroreAllaScheda(parse.errore);
|
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:
|
// 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
|
// 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.
|
// client_code in pages/api/longevity/questionario.ts, applicato al campo che qui conta.
|
||||||
const operatore = locals.user!.username;
|
const operatore = locals.user!.username;
|
||||||
const eta = etaDelCliente(clientCode, data);
|
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) {
|
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);
|
if (!esito.ok) return erroreAllaScheda(esito.errore);
|
||||||
const params = new URLSearchParams({ corretto: String(esito.sessioneId) });
|
const params = new URLSearchParams({ corretto: String(esito.sessioneId) });
|
||||||
if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length));
|
if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length));
|
||||||
return redirect(`/longevity/gestionale/cliente/${clientCode}?${params.toString()}`);
|
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);
|
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) });
|
const params = new URLSearchParams({ salvato: String(esito.sessioneId) });
|
||||||
if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length));
|
if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length));
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
import Longevity from '../../../../../layouts/Longevity.astro';
|
import Longevity from '../../../../../layouts/Longevity.astro';
|
||||||
import { getLongevityDb } from '../../../../../lib/longevity/db';
|
import { getLongevityDb } from '../../../../../lib/longevity/db';
|
||||||
import { clienteDaCodice } from '../../../../../lib/longevity/anagrafica';
|
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;
|
export const prerender = false;
|
||||||
|
|
||||||
@@ -30,8 +32,13 @@ if (sessioneId !== null && (!sessione || sessione.client_code !== cliente.client
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dataForm = sessione?.data ?? oggi;
|
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]) ?? []);
|
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');
|
const errore = Astro.url.searchParams.get('errore');
|
||||||
---
|
---
|
||||||
<Longevity
|
<Longevity
|
||||||
@@ -55,17 +62,73 @@ const errore = Astro.url.searchParams.get('errore');
|
|||||||
<input type="date" name="data" value={dataForm} required />
|
<input type="date" name="data" value={dataForm} required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{gruppi.map((g) => (
|
{modulo.sezioni.map((s) => (
|
||||||
<section class="lg-form__blocco">
|
<section class="lg-form__blocco">
|
||||||
<h2>{g.asse}</h2>
|
<h2>{s.titolo}</h2>
|
||||||
{g.campi.map((c) => (
|
{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">
|
<label class="lg-field">
|
||||||
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
|
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
|
||||||
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
|
<input type="text" inputmode="decimal" name={c.id} value={valori.get(c.id) ?? ''} />
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</section>
|
</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">
|
<div class="lg-form__nav">
|
||||||
<a href={`/longevity/gestionale/cliente/${cliente.client_code}`}>Annulla</a>
|
<a href={`/longevity/gestionale/cliente/${cliente.client_code}`}>Annulla</a>
|
||||||
@@ -73,3 +136,19 @@ const errore = Astro.url.searchParams.get('errore');
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Longevity>
|
</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>
|
||||||
|
|||||||
@@ -156,6 +156,15 @@
|
|||||||
.lg-field--consenso legend { padding: 0 4px; font-weight: 500; color: var(--c-heading); }
|
.lg-field--consenso legend { padding: 0 4px; font-weight: 500; color: var(--c-heading); }
|
||||||
.lg-field--consenso label { display: flex; align-items: center; gap: 8px; font-weight: 400; }
|
.lg-field--consenso label { display: flex; align-items: center; gap: 8px; font-weight: 400; }
|
||||||
|
|
||||||
|
/* Sezioni ad alternative del check-up (#13): un fieldset per protocollo, radio in legenda. */
|
||||||
|
.lg-form__alternative { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.lg-form__alternativa { border: 1px solid var(--lg-line); padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.lg-form__alternativa legend { padding: 0 4px; }
|
||||||
|
.lg-form__alternativa legend label { display: flex; align-items: center; gap: 8px; font-weight: 500; color: var(--c-heading); }
|
||||||
|
.lg-form__soglia { margin: 0; font-size: .8rem; color: var(--c-text-light); }
|
||||||
|
.lg-form__alternativa details { font-size: .85rem; }
|
||||||
|
.lg-form__alternativa summary { cursor: pointer; color: var(--c-text-light); }
|
||||||
|
|
||||||
.lg-form__errore { color: var(--c-heading); font-weight: 600; margin: 0; }
|
.lg-form__errore { color: var(--c-heading); font-weight: 600; margin: 0; }
|
||||||
|
|
||||||
.lg-form__nav { display: flex; justify-content: space-between; gap: 12px; }
|
.lg-form__nav { display: flex; justify-content: space-between; gap: 12px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user