Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 680224e773 | |||
| 8aeb25f930 | |||
| 575a3988b7 | |||
| 498395dddb |
@@ -0,0 +1,157 @@
|
||||
// La prima consulenza (#15): una scheda sola per cliente — livello, obiettivo, note dello
|
||||
// screening iniziale — che il trainer aggiorna in ogni follow-up. Correggere e salvare
|
||||
// SOVRASCRIVE, nessuno storico (decisione di Adriano, 27/09/2026): il racconto dei cambi va
|
||||
// nel giornale di bordo (giornale.ts), non qui. Stesso vincolo del resto di longevity.db:
|
||||
// solo client_code, mai un nome.
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/;
|
||||
export const TESTO_MAX = 5000;
|
||||
|
||||
// Elenco chiuso dell'obiettivo primario (foglio «Profilo Cliente», call del 27/09): chiave
|
||||
// salvata nel DB, etichetta mostrata nel form — stesso schema di ETICHETTA_TIPO altrove nel
|
||||
// gestionale.
|
||||
export const OBIETTIVI: { chiave: string; etichetta: string }[] = [
|
||||
{ chiave: 'performance_sportiva', etichetta: 'Performance sportiva' },
|
||||
{ chiave: 'energia_quotidiana', etichetta: 'Energia quotidiana' },
|
||||
{ chiave: 'recupero_benessere', etichetta: 'Recupero e benessere' },
|
||||
{ chiave: 'riabilitazione', etichetta: 'Riabilitazione' },
|
||||
{ chiave: 'ricomposizione_corporea', etichetta: 'Ricomposizione corporea' },
|
||||
{ chiave: 'dimagrimento', etichetta: 'Dimagrimento' },
|
||||
{ chiave: 'altro', etichetta: 'Altro' },
|
||||
];
|
||||
const CHIAVI_OBIETTIVI = new Set(OBIETTIVI.map((o) => o.chiave));
|
||||
|
||||
export type Consulenza = {
|
||||
client_code: string;
|
||||
livello: number | null;
|
||||
obiettivo_primario: string | null;
|
||||
obiettivo_specifico: string | null;
|
||||
data_target: string | null;
|
||||
note_mediche: string | null;
|
||||
storia_sportiva: string | null;
|
||||
limitazioni: string | null;
|
||||
disponibilita_giorni: number | null;
|
||||
fascia_oraria: string | null;
|
||||
note_screening: string | null;
|
||||
aggiornata_da: string;
|
||||
aggiornata_at: string;
|
||||
};
|
||||
|
||||
/** La scheda del cliente, o null se non è mai stata compilata. */
|
||||
export function consulenzaDi(db: Database.Database, clientCode: string): Consulenza | null {
|
||||
return (db.prepare(`SELECT * FROM consulenza WHERE client_code = ?`).get(clientCode) as
|
||||
Consulenza | undefined) ?? null;
|
||||
}
|
||||
|
||||
// I campi grezzi, tutti facoltativi e tutti stringhe: chi chiama (l'API) li legge dal
|
||||
// FormData così come sono, la validazione vera è qui — stesso confine di aggiungiVoce
|
||||
// (giornale.ts), che riceve stringhe e decide se sono valide.
|
||||
export type CampiConsulenza = {
|
||||
livello?: string;
|
||||
obiettivoPrimario?: string;
|
||||
obiettivoSpecifico?: string;
|
||||
dataTarget?: string;
|
||||
noteMediche?: string;
|
||||
storiaSportiva?: string;
|
||||
limitazioni?: string;
|
||||
disponibilitaGiorni?: string;
|
||||
fasciaOraria?: string;
|
||||
noteScreening?: string;
|
||||
};
|
||||
|
||||
export type EsitoSalvataggio = { ok: true } | { ok: false; errore: string };
|
||||
|
||||
// Una stringa vuota (dopo trim) diventa NULL: tutti i campi sono facoltativi (decisione di
|
||||
// Adriano, 27/09/2026), e NULL è "non compilato", non un errore.
|
||||
const nullSeVuoto = (v: string | undefined): string | null => {
|
||||
const t = (v ?? '').trim();
|
||||
return t === '' ? null : t;
|
||||
};
|
||||
|
||||
const testoValido = (v: string | null): v is string | null => v === null || v.length <= TESTO_MAX;
|
||||
|
||||
/**
|
||||
* Scrive (o riscrive) la scheda di prima consulenza. Rifiuta PRIMA di scrivere: soggetto
|
||||
* inesistente, livello fuori 1-3, obiettivo fuori elenco, data_target malformata,
|
||||
* disponibilità fuori 0-7 o non intera, un testo libero oltre TESTO_MAX — stesso schema di
|
||||
* validazione di aggiungiVoce (giornale.ts) e salvaCheckup (gestionale.ts). UPSERT: una
|
||||
* seconda chiamata sullo stesso client_code sovrascrive la prima, niente storico.
|
||||
*/
|
||||
export function salvaConsulenza(
|
||||
db: Database.Database,
|
||||
input: { clientCode: string; autore: string; campi: CampiConsulenza }
|
||||
): EsitoSalvataggio {
|
||||
const soggetto = db.prepare(`SELECT 1 FROM soggetti WHERE client_code = ?`).get(input.clientCode);
|
||||
if (!soggetto) {
|
||||
return { ok: false, errore: `Cliente inesistente: ${input.clientCode}` };
|
||||
}
|
||||
|
||||
const c = input.campi;
|
||||
|
||||
let livello: number | null = null;
|
||||
const livelloGrezzo = (c.livello ?? '').trim();
|
||||
if (livelloGrezzo !== '') {
|
||||
livello = Number(livelloGrezzo);
|
||||
if (!Number.isInteger(livello) || livello < 1 || livello > 3) {
|
||||
return { ok: false, errore: 'Livello non valido: deve essere 1, 2 o 3.' };
|
||||
}
|
||||
}
|
||||
|
||||
const obiettivoPrimario = nullSeVuoto(c.obiettivoPrimario);
|
||||
if (obiettivoPrimario !== null && !CHIAVI_OBIETTIVI.has(obiettivoPrimario)) {
|
||||
return { ok: false, errore: `Obiettivo primario non valido: ${obiettivoPrimario}` };
|
||||
}
|
||||
|
||||
const dataTarget = nullSeVuoto(c.dataTarget);
|
||||
if (dataTarget !== null && !REGEX_DATA.test(dataTarget)) {
|
||||
return { ok: false, errore: 'Data target non valida (formato AAAA-MM-GG).' };
|
||||
}
|
||||
|
||||
let disponibilitaGiorni: number | null = null;
|
||||
const disponibilitaGrezza = (c.disponibilitaGiorni ?? '').trim();
|
||||
if (disponibilitaGrezza !== '') {
|
||||
disponibilitaGiorni = Number(disponibilitaGrezza);
|
||||
if (!Number.isInteger(disponibilitaGiorni) || disponibilitaGiorni < 0 || disponibilitaGiorni > 7) {
|
||||
return { ok: false, errore: 'Disponibilità non valida: deve essere un numero intero fra 0 e 7.' };
|
||||
}
|
||||
}
|
||||
|
||||
const obiettivoSpecifico = nullSeVuoto(c.obiettivoSpecifico);
|
||||
const noteMediche = nullSeVuoto(c.noteMediche);
|
||||
const storiaSportiva = nullSeVuoto(c.storiaSportiva);
|
||||
const limitazioni = nullSeVuoto(c.limitazioni);
|
||||
const fasciaOraria = nullSeVuoto(c.fasciaOraria);
|
||||
const noteScreening = nullSeVuoto(c.noteScreening);
|
||||
|
||||
for (const [nome, valore] of [
|
||||
['Obiettivo specifico', obiettivoSpecifico], ['Note mediche', noteMediche],
|
||||
['Storia sportiva', storiaSportiva], ['Limitazioni', limitazioni],
|
||||
['Fascia oraria', fasciaOraria], ['Note screening', noteScreening],
|
||||
] as const) {
|
||||
if (!testoValido(valore)) {
|
||||
return { ok: false, errore: `${nome} non può superare ${TESTO_MAX} caratteri.` };
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO consulenza (
|
||||
client_code, livello, obiettivo_primario, obiettivo_specifico, data_target,
|
||||
note_mediche, storia_sportiva, limitazioni, disponibilita_giorni, fascia_oraria,
|
||||
note_screening, aggiornata_da, aggiornata_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT (client_code) DO UPDATE SET
|
||||
livello = excluded.livello, obiettivo_primario = excluded.obiettivo_primario,
|
||||
obiettivo_specifico = excluded.obiettivo_specifico, data_target = excluded.data_target,
|
||||
note_mediche = excluded.note_mediche, storia_sportiva = excluded.storia_sportiva,
|
||||
limitazioni = excluded.limitazioni, disponibilita_giorni = excluded.disponibilita_giorni,
|
||||
fascia_oraria = excluded.fascia_oraria, note_screening = excluded.note_screening,
|
||||
aggiornata_da = excluded.aggiornata_da, aggiornata_at = excluded.aggiornata_at`
|
||||
).run(
|
||||
input.clientCode, livello, obiettivoPrimario, obiettivoSpecifico, dataTarget,
|
||||
noteMediche, storiaSportiva, limitazioni, disponibilitaGiorni, fasciaOraria,
|
||||
noteScreening, input.autore
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -122,6 +122,24 @@ CREATE TABLE IF NOT EXISTS giornale (
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_giornale_cliente ON giornale (client_code, data DESC);
|
||||
-- Prima consulenza (issue #15): una scheda sola per cliente, sovrascritta a ogni modifica —
|
||||
-- nessuno storico (decisione di Adriano, 27/09/2026, come il giornale qui sopra ma senza
|
||||
-- neanche l'aggiungi/cancella: qui è un UPSERT unico). Solo client_code, mai un nome.
|
||||
CREATE TABLE IF NOT EXISTS consulenza (
|
||||
client_code TEXT PRIMARY KEY REFERENCES soggetti(client_code),
|
||||
livello INTEGER CHECK (livello IN (1,2,3)),
|
||||
obiettivo_primario TEXT,
|
||||
obiettivo_specifico TEXT,
|
||||
data_target TEXT,
|
||||
note_mediche TEXT,
|
||||
storia_sportiva TEXT,
|
||||
limitazioni TEXT,
|
||||
disponibilita_giorni INTEGER CHECK (disponibilita_giorni BETWEEN 0 AND 7),
|
||||
fascia_oraria TEXT,
|
||||
note_screening TEXT,
|
||||
aggiornata_da TEXT NOT NULL,
|
||||
aggiornata_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`;
|
||||
|
||||
const SCHEMA_IDENTITY = `
|
||||
|
||||
@@ -165,5 +165,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
|
||||
const params = new URLSearchParams({ salvato: String(esito.sessioneId) });
|
||||
if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length));
|
||||
// Passo 3 → 4 del flusso guidato (#15): solo un check-up NUOVO (mai una correzione, che
|
||||
// non passa da questo ramo) con flusso=nuovo prosegue verso il questionario.
|
||||
const flusso = String(form.get('flusso') ?? '').trim();
|
||||
if (flusso === 'nuovo') params.set('flusso', 'questionario');
|
||||
return redirect(`/longevity/gestionale/cliente/${clientCode}?${params.toString()}`);
|
||||
};
|
||||
|
||||
@@ -52,5 +52,7 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
telefono: telefono || undefined,
|
||||
});
|
||||
|
||||
return redirect(`/longevity/gestionale/cliente/${code}`);
|
||||
// Dopo l'apertura del fascicolo comincia il flusso guidato (#15, passo 1 → 2): prima
|
||||
// consulenza, poi check-up, poi questionario — non più direttamente alla scheda.
|
||||
return redirect(`/longevity/gestionale/cliente/${code}/consulenza?flusso=nuovo`);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getLongevityDb } from '../../../../lib/longevity/db';
|
||||
import { salvaConsulenza } from '../../../../lib/longevity/consulenza';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// Riservato a admin+trainer da src/lib/auth.ts (RULES, /^\/api\/longevity\/gestionale(\/|$)/).
|
||||
|
||||
const REGEX_CODE = /^ISL-\d{4}$/;
|
||||
|
||||
const redirect = (location: string): Response => new Response(null, { status: 303, headers: { Location: location } });
|
||||
|
||||
/**
|
||||
* Salva la scheda di prima consulenza (#15). Autore SEMPRE da `locals.user`, mai dal form —
|
||||
* stesso principio di giornale.ts e checkup.ts. Il flusso guidato (`flusso=nuovo`, passo 1
|
||||
* → 2) prosegue verso il check-up; fuori dal flusso torna alla scheda cliente, sezione
|
||||
* #consulenza dove il form e la lettura vivono.
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const form = await request.formData();
|
||||
const clientCode = String(form.get('client_code') ?? '').trim();
|
||||
const flusso = String(form.get('flusso') ?? '').trim();
|
||||
|
||||
if (!REGEX_CODE.test(clientCode)) {
|
||||
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Codice cliente non valido.')}`);
|
||||
}
|
||||
|
||||
// Il ritorno in caso di errore resta sulla stessa pagina del form, conservando `flusso`:
|
||||
// altrimenti un errore fuori dal flusso guidato riporterebbe dentro il flusso, o viceversa.
|
||||
const query = new URLSearchParams();
|
||||
if (flusso !== '') query.set('flusso', flusso);
|
||||
const tornaAlModulo = (testo: string) => {
|
||||
const q = new URLSearchParams(query);
|
||||
q.set('errore', testo);
|
||||
return redirect(`/longevity/gestionale/cliente/${clientCode}/consulenza?${q.toString()}`);
|
||||
};
|
||||
|
||||
const db = getLongevityDb();
|
||||
const autore = locals.user!.username;
|
||||
|
||||
const esito = salvaConsulenza(db, {
|
||||
clientCode,
|
||||
autore,
|
||||
campi: {
|
||||
livello: String(form.get('livello') ?? ''),
|
||||
obiettivoPrimario: String(form.get('obiettivo_primario') ?? ''),
|
||||
obiettivoSpecifico: String(form.get('obiettivo_specifico') ?? ''),
|
||||
dataTarget: String(form.get('data_target') ?? ''),
|
||||
noteMediche: String(form.get('note_mediche') ?? ''),
|
||||
storiaSportiva: String(form.get('storia_sportiva') ?? ''),
|
||||
limitazioni: String(form.get('limitazioni') ?? ''),
|
||||
disponibilitaGiorni: String(form.get('disponibilita_giorni') ?? ''),
|
||||
fasciaOraria: String(form.get('fascia_oraria') ?? ''),
|
||||
noteScreening: String(form.get('note_screening') ?? ''),
|
||||
},
|
||||
});
|
||||
if (!esito.ok) return tornaAlModulo(esito.errore);
|
||||
|
||||
if (flusso === 'nuovo') {
|
||||
// Passo 2 → 3 del flusso guidato (#15): dopo la consulenza si va al check-up.
|
||||
return redirect(`/longevity/gestionale/cliente/${clientCode}/checkup?flusso=nuovo`);
|
||||
}
|
||||
return redirect(`/longevity/gestionale/cliente/${clientCode}?consulenza=salvata#consulenza`);
|
||||
};
|
||||
@@ -13,6 +13,7 @@ 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;
|
||||
|
||||
@@ -28,6 +29,8 @@ 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',
|
||||
@@ -55,6 +58,10 @@ const MESSAGGIO_GIORNALE: Record<'aggiunta' | 'cancellata', string> = {
|
||||
};
|
||||
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}`}
|
||||
@@ -82,6 +89,20 @@ const messaggioGiornale =
|
||||
{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
|
||||
@@ -96,6 +117,31 @@ const messaggioGiornale =
|
||||
<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>
|
||||
|
||||
@@ -40,6 +40,9 @@ 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');
|
||||
// Passo 3 del flusso guidato per un cliente nuovo (?flusso=nuovo, #15): solo per un
|
||||
// check-up NUOVO — in correzione (?sessione=<id>) non si è mai dentro il flusso guidato.
|
||||
const flusso = !sessione ? Astro.url.searchParams.get('flusso') ?? '' : '';
|
||||
---
|
||||
<Longevity
|
||||
title={`${sessione ? 'Modifica' : 'Nuovo'} check-up — ${cliente.cognome} ${cliente.nome}`}
|
||||
@@ -49,6 +52,7 @@ const errore = Astro.url.searchParams.get('errore');
|
||||
<div class="lg-head">
|
||||
<h1>{sessione ? 'Modifica check-up' : 'Nuovo check-up'}</h1>
|
||||
<p>{cliente.cognome} {cliente.nome} — {cliente.client_code}. Lascia vuoto ciò che non hai misurato.</p>
|
||||
{flusso === 'nuovo' && <p class="lg-muted">Passo 3 di 4 — Scheda Rapida</p>}
|
||||
</div>
|
||||
|
||||
{errore && <p class="lg-form__errore">{errore}</p>}
|
||||
@@ -56,6 +60,7 @@ const errore = Astro.url.searchParams.get('errore');
|
||||
<form class="lg-form" method="post" action="/api/longevity/gestionale/checkup">
|
||||
<input type="hidden" name="client_code" value={cliente.client_code} />
|
||||
{sessione && <input type="hidden" name="sessione_id" value={sessione.id} />}
|
||||
{flusso === 'nuovo' && <input type="hidden" name="flusso" value="nuovo" />}
|
||||
|
||||
<label class="lg-field">
|
||||
<span>Data</span>
|
||||
@@ -131,7 +136,11 @@ const errore = Astro.url.searchParams.get('errore');
|
||||
)}
|
||||
|
||||
<div class="lg-form__nav">
|
||||
<a href={`/longevity/gestionale/cliente/${cliente.client_code}`}>Annulla</a>
|
||||
{flusso === 'nuovo' ? (
|
||||
<a href={`/longevity/gestionale/cliente/${cliente.client_code}?flusso=questionario`}>Salta</a>
|
||||
) : (
|
||||
<a href={`/longevity/gestionale/cliente/${cliente.client_code}`}>Annulla</a>
|
||||
)}
|
||||
<button type="submit">{sessione ? 'Salva correzione' : 'Salva check-up'}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
// La scheda di prima consulenza (#15): livello, obiettivo, note dello screening iniziale.
|
||||
// Precompilata se esiste già — correggere e salvare SOVRASCRIVE, nessuno storico (decisione
|
||||
// di Adriano, 27/09/2026). L'invio va all'API del gestionale, che fa la validazione vera
|
||||
// (pages/api/longevity/gestionale/consulenza.ts) — qui c'è solo il form.
|
||||
//
|
||||
// Passo 2 del flusso guidato per un cliente nuovo (?flusso=nuovo, #15): il parametro passa
|
||||
// di pagina in pagina fino al questionario, e ogni passo ha un link «Salta».
|
||||
import Longevity from '../../../../../layouts/Longevity.astro';
|
||||
import { getLongevityDb } from '../../../../../lib/longevity/db';
|
||||
import { clienteDaCodice } from '../../../../../lib/longevity/anagrafica';
|
||||
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 scheda = consulenzaDi(db, cliente.client_code);
|
||||
const errore = Astro.url.searchParams.get('errore');
|
||||
const flusso = Astro.url.searchParams.get('flusso') ?? '';
|
||||
const query = flusso ? `?flusso=${encodeURIComponent(flusso)}` : '';
|
||||
---
|
||||
<Longevity
|
||||
title={`Prima consulenza — ${cliente.cognome} ${cliente.nome}`}
|
||||
crumbs={['Clienti', `${cliente.cognome} ${cliente.nome}`, 'Prima consulenza']}
|
||||
variante="gestionale"
|
||||
>
|
||||
<div class="lg-head">
|
||||
<h1>Prima consulenza</h1>
|
||||
<p>{cliente.cognome} {cliente.nome} — {cliente.client_code}. Tutti i campi sono facoltativi.</p>
|
||||
{flusso === 'nuovo' && <p class="lg-muted">Passo 2 di 4 — Prima consulenza</p>}
|
||||
</div>
|
||||
|
||||
{errore && <p class="lg-form__errore">{errore}</p>}
|
||||
|
||||
<form class="lg-form" method="post" action="/api/longevity/gestionale/consulenza">
|
||||
<input type="hidden" name="client_code" value={cliente.client_code} />
|
||||
<input type="hidden" name="flusso" value={flusso} />
|
||||
|
||||
<section class="lg-form__blocco">
|
||||
<fieldset class="lg-field lg-field--consenso">
|
||||
<legend>Livello</legend>
|
||||
{[1, 2, 3].map((n) => (
|
||||
<label>
|
||||
<input type="radio" name="livello" value={n} checked={scheda?.livello === n} /> {n}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="lg-field lg-field--consenso">
|
||||
<legend>Obiettivo primario</legend>
|
||||
{OBIETTIVI.map((o) => (
|
||||
<label>
|
||||
<input
|
||||
type="radio" name="obiettivo_primario" value={o.chiave}
|
||||
checked={scheda?.obiettivo_primario === o.chiave}
|
||||
/> {o.etichetta}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<label class="lg-field">
|
||||
<span>Obiettivo specifico concordato</span>
|
||||
<textarea name="obiettivo_specifico" rows="3">{scheda?.obiettivo_specifico ?? ''}</textarea>
|
||||
</label>
|
||||
|
||||
<label class="lg-field">
|
||||
<span>Data target</span>
|
||||
<input type="date" name="data_target" value={scheda?.data_target ?? ''} />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="lg-form__blocco">
|
||||
<h2>Screening</h2>
|
||||
<label class="lg-field">
|
||||
<span>Note mediche, allergie, infortuni pregressi</span>
|
||||
<textarea name="note_mediche" rows="3">{scheda?.note_mediche ?? ''}</textarea>
|
||||
</label>
|
||||
<label class="lg-field">
|
||||
<span>Storia sportiva</span>
|
||||
<textarea name="storia_sportiva" rows="3">{scheda?.storia_sportiva ?? ''}</textarea>
|
||||
</label>
|
||||
<label class="lg-field">
|
||||
<span>Limitazioni fisiche attuali</span>
|
||||
<textarea name="limitazioni" rows="3">{scheda?.limitazioni ?? ''}</textarea>
|
||||
</label>
|
||||
<label class="lg-field">
|
||||
<span>Disponibilità (giorni/settimana)</span>
|
||||
<input type="text" inputmode="numeric" name="disponibilita_giorni" value={scheda?.disponibilita_giorni ?? ''} />
|
||||
</label>
|
||||
<label class="lg-field">
|
||||
<span>Fascia oraria preferita</span>
|
||||
<input type="text" name="fascia_oraria" value={scheda?.fascia_oraria ?? ''} />
|
||||
</label>
|
||||
<label class="lg-field">
|
||||
<span>Note screening</span>
|
||||
<textarea name="note_screening" rows="3">{scheda?.note_screening ?? ''}</textarea>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="lg-form__nav">
|
||||
{flusso === 'nuovo' ? (
|
||||
<a href={`/longevity/gestionale/cliente/${cliente.client_code}/checkup${query}`}>Salta</a>
|
||||
) : (
|
||||
<a href={`/longevity/gestionale/cliente/${cliente.client_code}#consulenza`}>Annulla</a>
|
||||
)}
|
||||
<button type="submit">Salva</button>
|
||||
</div>
|
||||
</form>
|
||||
</Longevity>
|
||||
@@ -154,13 +154,15 @@
|
||||
|
||||
.lg-field--consenso { border: 1px solid var(--lg-line); padding: 16px 18px; gap: 10px; }
|
||||
.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; }
|
||||
/* Bersaglio touch >= 44px (#15, tablet del centro): l'input radio/checkbox resta piccolo,
|
||||
ma l'intera label (dove il dito tocca davvero) ha l'altezza minima del criterio WCAG. */
|
||||
.lg-field--consenso label { display: flex; align-items: center; gap: 8px; font-weight: 400; min-height: 44px; }
|
||||
|
||||
/* 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__alternativa legend label { display: flex; align-items: center; gap: 8px; font-weight: 500; color: var(--c-heading); min-height: 44px; } /* bersaglio touch (#15) */
|
||||
.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); }
|
||||
@@ -172,6 +174,7 @@
|
||||
font-family: var(--font-heading); font-size: .78rem; font-weight: 600; letter-spacing: .08em;
|
||||
text-transform: uppercase; padding: 11px 22px; border: 1px solid var(--c-heading);
|
||||
background: transparent; color: var(--c-heading); cursor: pointer;
|
||||
min-height: 44px; /* bersaglio touch (#15) */
|
||||
}
|
||||
.lg-form__nav button:disabled { opacity: .35; cursor: not-allowed; }
|
||||
.lg-form__nav button:last-child:not(:only-child) { background: var(--c-accent); border-color: var(--c-accent); color: #fff; }
|
||||
@@ -219,9 +222,10 @@
|
||||
|
||||
/* bottone pieno per un link che porta a un'azione (non un <button> di form) */
|
||||
.lg-btn {
|
||||
display: inline-block; font-family: var(--font-heading); font-size: .76rem; font-weight: 600;
|
||||
display: inline-flex; align-items: center; font-family: var(--font-heading); font-size: .76rem; font-weight: 600;
|
||||
letter-spacing: .06em; text-transform: uppercase; padding: 10px 20px; border: 1px solid var(--c-accent);
|
||||
background: var(--c-accent); color: var(--lg-surface); text-decoration: none;
|
||||
min-height: 44px; box-sizing: border-box; /* bersaglio touch (#15) */
|
||||
}
|
||||
.lg-btn:hover { background: var(--c-accent-dark); border-color: var(--c-accent-dark); }
|
||||
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
// Issue #15: prima consulenza per cliente (solo staff, una scheda sola — correggere e
|
||||
// salvare sovrascrive, nessuno storico), e il flusso guidato per un cliente nuovo.
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createLongevityDb, getLongevityDb } from '../../src/lib/longevity/db';
|
||||
import { salvaCheckup } from '../../src/lib/longevity/gestionale';
|
||||
import { consulenzaDi, salvaConsulenza, OBIETTIVI } from '../../src/lib/longevity/consulenza';
|
||||
|
||||
function dbPronto() {
|
||||
const db = createLongevityDb(':memory:');
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('salvaConsulenza e consulenzaDi', () => {
|
||||
it('inserisce e legge la scheda', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, {
|
||||
clientCode: 'ISL-0001', autore: 'nicola',
|
||||
campi: { livello: '2', obiettivoPrimario: 'dimagrimento', dataTarget: '2027-01-01', disponibilitaGiorni: '3' },
|
||||
});
|
||||
expect(esito.ok).toBe(true);
|
||||
|
||||
const scheda = consulenzaDi(db, 'ISL-0001');
|
||||
expect(scheda).not.toBeNull();
|
||||
expect(scheda?.livello).toBe(2);
|
||||
expect(scheda?.obiettivo_primario).toBe('dimagrimento');
|
||||
expect(scheda?.data_target).toBe('2027-01-01');
|
||||
expect(scheda?.disponibilita_giorni).toBe(3);
|
||||
expect(scheda?.aggiornata_da).toBe('nicola');
|
||||
});
|
||||
|
||||
it('null se non compilata', () => {
|
||||
const db = dbPronto();
|
||||
expect(consulenzaDi(db, 'ISL-0001')).toBeNull();
|
||||
});
|
||||
|
||||
it('una seconda chiamata sovrascrive la prima: una riga sola, nessuno storico', () => {
|
||||
const db = dbPronto();
|
||||
salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '1' } });
|
||||
salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'donata', campi: { livello: '3' } });
|
||||
|
||||
const righe = (db.prepare(`SELECT COUNT(*) n FROM consulenza`).get() as { n: number }).n;
|
||||
expect(righe).toBe(1);
|
||||
const scheda = consulenzaDi(db, 'ISL-0001');
|
||||
expect(scheda?.livello).toBe(3);
|
||||
expect(scheda?.aggiornata_da).toBe('donata');
|
||||
});
|
||||
|
||||
it('stringhe vuote diventano NULL', () => {
|
||||
const db = dbPronto();
|
||||
salvaConsulenza(db, {
|
||||
clientCode: 'ISL-0001', autore: 'nicola',
|
||||
campi: { obiettivoSpecifico: ' ', noteMediche: '', fasciaOraria: undefined },
|
||||
});
|
||||
const scheda = consulenzaDi(db, 'ISL-0001');
|
||||
expect(scheda?.obiettivo_specifico).toBeNull();
|
||||
expect(scheda?.note_mediche).toBeNull();
|
||||
expect(scheda?.fascia_oraria).toBeNull();
|
||||
});
|
||||
|
||||
it('tutti i campi sono facoltativi: una scheda vuota è valida', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: {} });
|
||||
expect(esito.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('salvaConsulenza: rifiuti, nessuna scrittura', () => {
|
||||
const righe = (db: ReturnType<typeof dbPronto>) =>
|
||||
(db.prepare(`SELECT COUNT(*) n FROM consulenza`).get() as { n: number }).n;
|
||||
|
||||
it('rifiuta un cliente inesistente', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-9999', autore: 'nicola', campi: {} });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(righe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('rifiuta un livello fuori 1-3', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '4' } });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(righe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('rifiuta un livello non intero', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '2.5' } });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(righe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('rifiuta un obiettivo primario fuori elenco', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { obiettivoPrimario: 'volare' } });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(righe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('accetta ogni chiave dell elenco OBIETTIVI', () => {
|
||||
const db = dbPronto();
|
||||
for (const o of OBIETTIVI) {
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { obiettivoPrimario: o.chiave } });
|
||||
expect(esito.ok, o.chiave).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rifiuta una data target malformata', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { dataTarget: '01-01-2027' } });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(righe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('rifiuta una disponibilita fuori 0-7', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { disponibilitaGiorni: '8' } });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(righe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('rifiuta una disponibilita non intera', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { disponibilitaGiorni: '2,5' } });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(righe(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('rifiuta un testo libero troppo lungo', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { noteMediche: 'x'.repeat(5001) } });
|
||||
expect(esito.ok).toBe(false);
|
||||
expect(righe(db)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema: la tabella consulenza si crea su un longevity.db preesistente senza di essa', () => {
|
||||
it('CREATE TABLE IF NOT EXISTS basta: nessuna migrazione a mano serve', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
|
||||
expect(salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '1' } }).ok).toBe(true);
|
||||
expect(() => createLongevityDb(':memory:')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// Le API sotto (consulenza.ts e checkup.ts) chiamano SEMPRE getLongevityDb(), il singleton
|
||||
// di db.ts: aprirlo e chiuderlo dentro ogni singolo test (come fa correzione-checkup-api.test.ts
|
||||
// da solo nel suo file) romperebbe i test successivi dello STESSO file, perché il singleton
|
||||
// resta quello chiuso finché il modulo non si ricarica. Qui si apre una volta sola per
|
||||
// tutte le API, con beforeAll/afterAll, e ogni test usa un client_code diverso per non
|
||||
// pestarsi i piedi — stesso principio, applicato a un file con più test sullo stesso
|
||||
// singleton invece di uno solo.
|
||||
describe('API del flusso guidato (consulenza.ts, checkup.ts)', () => {
|
||||
let dir: string;
|
||||
let db: ReturnType<typeof getLongevityDb>;
|
||||
let primaLg: string | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'lg-api-flusso-test-'));
|
||||
primaLg = process.env.LONGEVITY_DB_PATH;
|
||||
process.env.LONGEVITY_DB_PATH = join(dir, 'longevity.db');
|
||||
db = getLongevityDb();
|
||||
for (const code of ['ISL-0001', 'ISL-0002', 'ISL-0003', 'ISL-0004']) {
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, 'F')`).run(code);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
db.close(); // libera l'handle sul file: senza, rmSync fallisce con EPERM su Windows
|
||||
if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('consulenza.ts: salva con autore da locals.user (mai dal form) e torna alla scheda cliente, fuori dal flusso', async () => {
|
||||
const { POST } = await import('../../src/pages/api/longevity/gestionale/consulenza');
|
||||
const form = new FormData();
|
||||
form.set('client_code', 'ISL-0001');
|
||||
form.set('livello', '2');
|
||||
form.set('autore', 'un-impostore'); // ignorato: deve vincere locals.user
|
||||
const richiesta = new Request('http://localhost/api/longevity/gestionale/consulenza', { method: 'POST', body: form });
|
||||
|
||||
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
|
||||
expect(res.status).toBe(303);
|
||||
expect(res.headers.get('Location')).toBe('/longevity/gestionale/cliente/ISL-0001?consulenza=salvata#consulenza');
|
||||
|
||||
const riga = db.prepare(`SELECT aggiornata_da FROM consulenza WHERE client_code = 'ISL-0001'`).get() as
|
||||
{ aggiornata_da: string };
|
||||
expect(riga.aggiornata_da).toBe('nicola');
|
||||
});
|
||||
|
||||
it('consulenza.ts: con flusso=nuovo prosegue verso il check-up', async () => {
|
||||
const { POST } = await import('../../src/pages/api/longevity/gestionale/consulenza');
|
||||
const form = new FormData();
|
||||
form.set('client_code', 'ISL-0002');
|
||||
form.set('flusso', 'nuovo');
|
||||
const richiesta = new Request('http://localhost/api/longevity/gestionale/consulenza', { method: 'POST', body: form });
|
||||
|
||||
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
|
||||
expect(res.headers.get('Location')).toBe('/longevity/gestionale/cliente/ISL-0002/checkup?flusso=nuovo');
|
||||
});
|
||||
|
||||
it('consulenza.ts: client_code inesistente, errore, torna al modulo conservando il flusso', async () => {
|
||||
const { POST } = await import('../../src/pages/api/longevity/gestionale/consulenza');
|
||||
const form = new FormData();
|
||||
form.set('client_code', 'ISL-9999');
|
||||
form.set('flusso', 'nuovo');
|
||||
const richiesta = new Request('http://localhost/api/longevity/gestionale/consulenza', { method: 'POST', body: form });
|
||||
|
||||
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
|
||||
expect(res.status).toBe(303);
|
||||
const loc = res.headers.get('Location')!;
|
||||
expect(loc).toMatch(/^\/longevity\/gestionale\/cliente\/ISL-9999\/consulenza\?/);
|
||||
expect(loc).toMatch(/flusso=nuovo/);
|
||||
expect(loc).toMatch(/errore=/);
|
||||
});
|
||||
|
||||
it('checkup.ts: un check-up NUOVO con flusso=nuovo redirige con flusso=questionario', async () => {
|
||||
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
|
||||
const form = new FormData();
|
||||
form.set('client_code', 'ISL-0003');
|
||||
form.set('data', '2026-09-26');
|
||||
form.set('flusso', 'nuovo');
|
||||
form.set('f_peso_kg', '80'); // salvaCheckup rifiuta un check-up senza nessuna misura
|
||||
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
|
||||
|
||||
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
|
||||
expect(res.status).toBe(303);
|
||||
const loc = res.headers.get('Location')!;
|
||||
expect(loc).toMatch(/^\/longevity\/gestionale\/cliente\/ISL-0003\?/);
|
||||
expect(loc).toMatch(/salvato=/);
|
||||
expect(loc).toMatch(/flusso=questionario/);
|
||||
});
|
||||
|
||||
it('checkup.ts: una correzione (sessione_id) ignora flusso, nessun flusso nel redirect', async () => {
|
||||
const esito = salvaCheckup(db, {
|
||||
client_code: 'ISL-0004', data: '2026-09-20', eta: 40, operatore: 'nicola',
|
||||
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
|
||||
});
|
||||
expect(esito.ok).toBe(true);
|
||||
if (!esito.ok) throw new Error(esito.errore);
|
||||
|
||||
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
|
||||
const form = new FormData();
|
||||
form.set('client_code', 'ISL-0004');
|
||||
form.set('data', '2026-09-20');
|
||||
form.set('sessione_id', String(esito.sessioneId));
|
||||
form.set('f_peso_kg', '82');
|
||||
form.set('flusso', 'nuovo'); // ignorato: una correzione non è mai nel flusso guidato
|
||||
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
|
||||
|
||||
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
|
||||
const loc = res.headers.get('Location')!;
|
||||
expect(loc).toMatch(/^\/longevity\/gestionale\/cliente\/ISL-0004\?/);
|
||||
expect(loc).toMatch(/corretto=/);
|
||||
expect(loc).not.toMatch(/flusso=/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clienti.ts: dopo la creazione il flusso guidato comincia dalla consulenza', () => {
|
||||
it('redirige a .../consulenza?flusso=nuovo, non piu direttamente alla scheda', () => {
|
||||
const fs = require('node:fs') as typeof import('node:fs');
|
||||
const path = require('node:path') as typeof import('node:path');
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src/pages/api/longevity/gestionale/clienti.ts'), 'utf8');
|
||||
expect(src).toMatch(/\/consulenza\?flusso=nuovo/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nessuna pagina cliente importa la consulenza', () => {
|
||||
it('io.astro, progressione.astro e questionario.astro non fanno riferimento a consulenza.ts', () => {
|
||||
const fs = require('node:fs') as typeof import('node:fs');
|
||||
const path = require('node:path') as typeof import('node:path');
|
||||
const pagine = ['io.astro', 'progressione.astro', 'questionario.astro'];
|
||||
for (const p of pagine) {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src/pages/longevity', p), 'utf8');
|
||||
expect(src, `${p} non deve importare consulenza.ts`).not.toMatch(/consulenza/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -129,6 +129,7 @@ describe('il gestionale del trainer (Unità 2b)', () => {
|
||||
'src/pages/longevity/gestionale/nuovo.astro',
|
||||
'src/pages/longevity/gestionale/cliente/[code].astro',
|
||||
'src/pages/longevity/gestionale/cliente/[code]/checkup.astro',
|
||||
'src/pages/longevity/gestionale/cliente/[code]/consulenza.astro', // #15
|
||||
];
|
||||
|
||||
it('ogni pagina del gestionale esiste e usa la variante "gestionale" del layout', () => {
|
||||
|
||||
Reference in New Issue
Block a user