Files
InsanityLab-web/apps/platforms/src/pages/longevity/gestionale/cliente/[code].astro
T
Adriano 8aeb25f930 longevity: #15 pagine del flusso guidato: consulenza, check-up, scheda cliente
Nuova pagina consulenza.astro (radio per livello e obiettivo, niente
select). checkup.astro mostra "Passo 3 di 4" e porta avanti flusso=nuovo,
con «Salta» verso la scheda. La scheda cliente ([code].astro) mostra la
sezione "Prima consulenza" (letta, Modifica/Compila) e, con
flusso=questionario, il riquadro "Passo 4 di 4" che rimanda a "Crea
accesso" se il cliente non lo ha ancora.

Refs #15
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 08:21:33 +02:00

288 lines
12 KiB
Plaintext
Raw 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.
---
// La scheda di un cliente: anagrafica, il referto dell'ultima sessione (lo stesso che il
// cliente vede da solo su /longevity/io, qui per il trainer), l'elenco delle sessioni e le
// misure fuori range. Un codice che non esiste in anagrafica è un 404, non uno schermo
// vuoto — non c'è niente da mostrare per un fascicolo che non c'è.
import Longevity from '../../../../layouts/Longevity.astro';
import Radar from '../../../../components/longevity/Radar';
import MacroScore from '../../../../components/longevity/MacroScore.astro';
import { getLongevityDb } from '../../../../lib/longevity/db';
import { clienteDaCodice, etaDelCliente } from '../../../../lib/longevity/anagrafica';
import { sessioniDi } from '../../../../lib/longevity/gestionale';
import { misureDaVerificare } from '../../../../lib/longevity/misure';
import { refertoDi, ultimaSessioneDi } from '../../../../lib/longevity/vista';
import { formattaNumero } from '../../../../lib/longevity/formato';
import { vociDelGiornale } from '../../../../lib/longevity/giornale';
import { consulenzaDi, OBIETTIVI } from '../../../../lib/longevity/consulenza';
export const prerender = false;
const { code } = Astro.params;
const cliente = code ? clienteDaCodice(code) : null;
if (!cliente) return new Response('Cliente non trovato', { status: 404 });
const db = getLongevityDb();
const oggi = new Date().toISOString().slice(0, 10);
const eta = etaDelCliente(cliente.client_code, oggi);
const sessioni = sessioniDi(db, cliente.client_code);
const daVerificare = misureDaVerificare(db, cliente.client_code);
const ultimaId = ultimaSessioneDi(db, cliente.client_code);
const referto = ultimaId !== null ? refertoDi(db, ultimaId) : null;
const voci = vociDelGiornale(db, cliente.client_code); // #16: giornale di bordo, solo staff
const consulenza = consulenzaDi(db, cliente.client_code); // #15: prima consulenza, solo staff
const ETICHETTA_OBIETTIVO = new Map(OBIETTIVI.map((o) => [o.chiave, o.etichetta]));
const ETICHETTA_TIPO: Record<'checkup' | 'questionario', string> = {
checkup: 'Check-up',
questionario: 'Questionario',
};
const formattaData = (iso: string) =>
new Date(`${iso}T00:00:00`).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' });
const params = Astro.url.searchParams;
const salvato = params.get('salvato');
const corretto = params.get('corretto'); // #12: redirect di correggiCheckup dopo una correzione
const fuoriCount = params.get('fuori');
const errore = params.get('errore');
const verificata = params.get('verificata');
const MESSAGGIO_VERIFICA: Record<'confermata' | 'scartata', string> = {
confermata: 'Misura confermata: punteggi ricalcolati.',
scartata: 'Misura scartata.',
};
const messaggioVerifica =
verificata === 'confermata' || verificata === 'scartata' ? MESSAGGIO_VERIFICA[verificata] : null;
const giornaleEsito = params.get('giornale'); // #16: 'aggiunta' | 'cancellata', dal redirect dell'API
const MESSAGGIO_GIORNALE: Record<'aggiunta' | 'cancellata', string> = {
aggiunta: 'Nota aggiunta al giornale di bordo.',
cancellata: 'Nota cancellata dal giornale di bordo.',
};
const messaggioGiornale =
giornaleEsito === 'aggiunta' || giornaleEsito === 'cancellata' ? MESSAGGIO_GIORNALE[giornaleEsito] : null;
const consulenzaEsito = params.get('consulenza'); // #15: 'salvata', dal redirect dell'API consulenza
// Passo 4 del flusso guidato per un cliente nuovo (#15): dopo il check-up si torna qui con
// ?flusso=questionario — il questionario resta del cliente, quindi qui c'è solo l'indicazione.
const flusso = params.get('flusso');
---
<Longevity
title={`${cliente.cognome} ${cliente.nome}`}
crumbs={['Clienti', `${cliente.cognome} ${cliente.nome}`]}
variante="gestionale"
>
<div class="lg-head lg-head--azioni">
<div>
<h1>{cliente.cognome} {cliente.nome}</h1>
<p>
{cliente.client_code} · {eta !== null ? `${eta} anni` : 'età non registrata'} ·
accesso {cliente.ha_accesso ? 'attivo' : 'non ancora attivato'}
</p>
</div>
<div>
{!cliente.ha_accesso && (
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/accesso`}>Crea accesso al referto</a>
)}
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/checkup`}>Nuovo check-up</a>
</div>
</div>
{errore && <p class="lg-form__errore">{errore}</p>}
{salvato && <p class="lg-card">Check-up salvato (sessione #{salvato}).</p>}
{corretto && <p class="lg-card">Check-up corretto (sessione #{corretto}): punteggi ricalcolati.</p>}
{messaggioVerifica && <p class="lg-card">{messaggioVerifica}</p>}
{messaggioGiornale && <p class="lg-card">{messaggioGiornale}</p>}
{consulenzaEsito === 'salvata' && <p class="lg-card">Prima consulenza salvata.</p>}
{flusso === 'questionario' && (
<div class="lg-card">
<p class="lg-score__label">Passo 4 di 4 — Questionario</p>
{cliente.ha_accesso ? (
<p>Il questionario lo compila il cliente dal suo accesso, anche sul tablet del centro.</p>
) : (
<>
<p>Il questionario lo compila il cliente dal suo accesso, anche sul tablet del centro: crealo per continuare.</p>
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/accesso`}>Crea accesso al referto</a>
</>
)}
</div>
)}
{fuoriCount && (
<p class="lg-misura--fuori-range">
{fuoriCount} {Number(fuoriCount) === 1 ? 'misura è' : 'misure sono'} fuori dal range di
plausibilità: verificale più sotto.
</p>
)}
<div class="lg-card">
<p class="lg-score__label">Anagrafica</p>
<p>Email: {cliente.email ?? '—'}</p>
<p>Telefono: {cliente.telefono ?? '—'}</p>
<p>Data di nascita: {cliente.data_nascita ? formattaData(cliente.data_nascita) : '—'}</p>
</div>
<div class="lg-card" id="consulenza">
<div class="lg-head--azioni">
<p class="lg-score__label">Prima consulenza</p>
<a class="lg-btn" href={`/longevity/gestionale/cliente/${cliente.client_code}/consulenza`}>
{consulenza ? 'Modifica' : 'Compila'}
</a>
</div>
{!consulenza ? (
<p class="lg-muted">Non ancora compilata.</p>
) : (
<>
<p>Livello: {consulenza.livello ?? '—'}</p>
<p>Obiettivo primario: {consulenza.obiettivo_primario ? ETICHETTA_OBIETTIVO.get(consulenza.obiettivo_primario) ?? consulenza.obiettivo_primario : '—'}</p>
<p>Obiettivo specifico: {consulenza.obiettivo_specifico ?? '—'}</p>
<p>Data target: {consulenza.data_target ? formattaData(consulenza.data_target) : '—'}</p>
<p>Note mediche: {consulenza.note_mediche ?? '—'}</p>
<p>Storia sportiva: {consulenza.storia_sportiva ?? '—'}</p>
<p>Limitazioni fisiche attuali: {consulenza.limitazioni ?? '—'}</p>
<p>Disponibilità: {consulenza.disponibilita_giorni ?? '—'} giorni/settimana</p>
<p>Fascia oraria preferita: {consulenza.fascia_oraria ?? '—'}</p>
<p>Note screening: {consulenza.note_screening ?? '—'}</p>
</>
)}
</div>
<div class="lg-card" id="giornale">
<p class="lg-score__label">Giornale di bordo</p>
<p class="lg-muted">Solo lo staff lo vede. Una nota sbagliata si cancella e si riscrive: non si modifica.</p>
<form class="lg-form" method="post" action="/api/longevity/gestionale/giornale">
<input type="hidden" name="azione" value="aggiungi" />
<input type="hidden" name="client_code" value={cliente.client_code} />
<label class="lg-field">
<span>Data</span>
<input type="date" name="data" value={oggi} max={oggi} required />
</label>
<label class="lg-field">
<span>Nota</span>
<textarea name="testo" rows="3" required></textarea>
</label>
<div class="lg-form__nav">
<button type="submit">Aggiungi nota</button>
</div>
</form>
{voci.length === 0 ? (
<p class="lg-muted">Nessuna nota ancora.</p>
) : (
<table class="lg-table">
<thead><tr><th>Data</th><th>Autore</th><th>Nota</th><th></th></tr></thead>
<tbody>
{voci.map((v) => (
<tr>
<td>{formattaData(v.data)}</td>
<td>{v.autore}</td>
<td style="white-space: pre-wrap;">{v.testo}</td>
<td>
{(v.autore === Astro.locals.user!.username || Astro.locals.user!.role === 'admin') && (
<form method="post" action="/api/longevity/gestionale/giornale">
<input type="hidden" name="azione" value="cancella" />
<input type="hidden" name="id" value={v.id} />
<button type="submit">Cancella</button>
</form>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{!referto && (
<div class="lg-card lg-card--incompleto">
<p>Nessuna sessione ancora: apri un check-up per cominciare il fascicolo.</p>
</div>
)}
{referto && (
<div class="lg-referto">
<section class:list={['lg-card', { 'lg-card--incompleto': referto.fitnessAge === null }]}>
<p class="lg-score__label">Fitness Age</p>
<div class="lg-score">
<span class="lg-score__value lg-num">
{referto.fitnessAge !== null ? formattaNumero(referto.fitnessAge) : '—'}
</span>
{referto.fitnessAge !== null && <span class="lg-score__max">anni</span>}
</div>
<p class="lg-muted lg-macro__nota">
Copertura dati: {Math.round(referto.fitnessAgeCopertura * 100)}%
{referto.fitnessAge === null && ' — non ancora sufficiente per un numero.'}
</p>
</section>
<section class="lg-card">
<h2>Radar</h2>
<Radar client:load assi={referto.assi} />
</section>
<section class="lg-macro">
{referto.macro.map((m) => (
<MacroScore nome={m.nome} valore={m.valore} copertura={m.copertura} />
))}
</section>
</div>
)}
<div class="lg-card">
<p class="lg-score__label">Sessioni</p>
{sessioni.length === 0 ? (
<p class="lg-muted">Nessuna sessione.</p>
) : (
<table class="lg-table">
<thead>
<tr><th>Data</th><th>Tipo</th><th>Operatore</th><th>Misure</th><th>Fitness Age</th></tr>
</thead>
<tbody>
{sessioni.map((s) => (
<tr>
<td>
<a href={`/longevity/gestionale/cliente/${cliente.client_code}/sessione/${s.id}`}>{formattaData(s.data)}</a>
</td>
<td>{ETICHETTA_TIPO[s.tipo]}</td>
<td>{s.operatore ?? '—'}</td>
<td>{s.numeroMisure}</td>
<td>{s.fitnessAge !== null ? formattaNumero(s.fitnessAge) : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{daVerificare.length > 0 && (
<div class="lg-card">
<p class="lg-score__label">Misure da verificare</p>
<p class="lg-muted">
Una misura confermata è un dato vero: entra nei punteggi e ricalcola quella sessione e
le successive. Una scartata è un errore di strumento o protocollo: resta fuori per sempre.
</p>
<table class="lg-table">
<thead><tr><th>Data</th><th>Test</th><th>Valore</th><th>Range atteso</th><th>Decisione</th></tr></thead>
<tbody>
{daVerificare.map((m) => (
<tr>
<td>{formattaData(m.data)}</td>
<td>{m.etichetta}</td>
<td class="lg-misura--fuori-range">
{m.valore_num !== null ? formattaNumero(m.valore_num) : '—'}{m.unita ? ` ${m.unita}` : ''}
</td>
<td>{m.range_min ?? '—'}–{m.range_max ?? '—'}</td>
<td>
<form method="post" action="/api/longevity/gestionale/verifica">
<input type="hidden" name="misura_id" value={m.id} />
<button type="submit" name="esito" value="confermata">Conferma: il dato è vero</button>
</form>
<form method="post" action="/api/longevity/gestionale/verifica">
<input type="hidden" name="misura_id" value={m.id} />
<button type="submit" name="esito" value="scartata">Scarta: è un errore</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Longevity>