Piattaforme riservate: ruolo dedicato e port della dashboard Stress Index

Il ruolo `campus` diventa `piattaforme` e non apre più solo la sezione
Biohacking: copre tutte le aree riservate, con migrazione guardata sugli
utenti esistenti. Nell'header la vecchia voce Biohacking lascia il posto a
un menu Piattaforme con i due link, visibile solo a chi ha il ruolo, e
/piattaforme diventa la pagina di atterraggio.

Stress Index è portata dentro il sito: le nove viste dell'area
professionisti diventano pagine Astro sotto /piattaforme/stress-index,
con lo stile del sito al posto del design system del prototipo. React
resta solo dove serve davvero — grafici recharts e scheda cliente — il
resto è Astro con un filo di JS. I dati sono ancora quelli dimostrativi,
raccolti in lib/stress-index/data.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:49:47 +02:00
parent e8e24e086b
commit c4eb598a3a
32 changed files with 2908 additions and 74 deletions
@@ -0,0 +1,387 @@
// Scheda cliente: è l'unica vista davvero interattiva della piattaforma (tab, filtri sul
// grafico, note, messaggi, tag), quindi vive come singola isola React. Lo stile arriva dalle
// classi `.si-*` del foglio globale: qui non ci sono classi Tailwind del prototipo originale.
import { useMemo, useState } from 'react';
import DataChart from './charts/DataChart';
import type { Alert, Client, Measurement, Note } from '../../lib/stress-index/data';
interface Props {
client: Client;
measurements: Measurement[];
alerts: Alert[];
notes: Note[];
}
const TABS = [
{ key: 'panoramica', label: 'Panoramica' },
{ key: 'misurazioni', label: 'Misurazioni' },
{ key: 'analytics', label: 'Analytics' },
{ key: 'report', label: 'Report' },
{ key: 'note', label: 'Note' },
{ key: 'messaggi', label: 'Messaggi' },
{ key: 'impostazioni', label: 'Impostazioni' },
];
const METRICHE = [
{ key: 'stress', label: 'Stress', color: '#b05a4e' },
{ key: 'recovery', label: 'Recupero', color: '#6f8f6a' },
{ key: 'balance', label: 'Equilibrio', color: '#c08a3e' },
{ key: 'energy', label: 'Energia', color: '#9c8b70' },
];
const PERIODI = [
{ key: '4', label: 'Ultime 4' },
{ key: '8', label: 'Ultime 8' },
{ key: 'all', label: 'Tutte' },
];
const REPORT = [
{ date: '2026-07-01', label: 'Report mensile — Giugno 2026' },
{ date: '2026-06-01', label: 'Report mensile — Maggio 2026' },
{ date: '2026-05-01', label: 'Report mensile — Aprile 2026' },
];
const MESSAGGI_INIZIALI = [
{ from: 'cliente', text: "Buongiorno dottore, le invio l'esito della misurazione di stamattina.", when: '09:12' },
{ from: 'professionista', text: 'Grazie, la vedo. I valori di recupero sono in miglioramento rispetto alla scorsa settimana.', when: '09:20' },
{ from: 'cliente', text: 'Sì, mi sento meglio, dormo di più.', when: '09:22' },
{ from: 'professionista', text: 'Continui così, ci sentiamo al prossimo controllo.', when: '09:25' },
];
const qualitativa = (v: number) => (v >= 70 ? 'Alto' : v >= 40 ? 'Moderato' : 'Basso');
const livello = (v: number) => (v >= 70 ? 'bad' : v >= 45 ? 'warn' : 'ok');
/** Indicatore ad arco: il valore riempie un semicerchio da sinistra a destra. */
function Gauge({ value, label }: { value: number; label: string }) {
const ratio = Math.max(0, Math.min(1, value / 100));
const R = 56;
const len = Math.PI * R;
return (
<div className="si-gauge">
<svg viewBox="0 0 140 78" className="si-gauge__svg" role="img" aria-label={`${label}: ${value} su 100`}>
<path d="M 14 70 A 56 56 0 0 1 126 70" fill="none" stroke="#e4dfd7" strokeWidth="10" strokeLinecap="round" />
<path
d="M 14 70 A 56 56 0 0 1 126 70" fill="none" strokeWidth="10" strokeLinecap="round"
stroke={value >= 70 ? '#b05a4e' : value >= 45 ? '#c08a3e' : '#6f8f6a'}
strokeDasharray={`${len * ratio} ${len}`}
/>
</svg>
<p className="si-gauge__value">{value}<span className="si-gauge__max">/100</span></p>
<p className="si-gauge__label">{label} · {qualitativa(value)}</p>
</div>
);
}
export default function ClientDetail({ client, measurements, alerts, notes }: Props) {
const [tab, setTab] = useState('panoramica');
const [periodo, setPeriodo] = useState('8');
const [metriche, setMetriche] = useState<string[]>(METRICHE.map((m) => m.key));
const [noteCliente, setNoteCliente] = useState(notes);
const [nuovaNota, setNuovaNota] = useState('');
const [messaggi, setMessaggi] = useState(MESSAGGI_INIZIALI);
const [bozza, setBozza] = useState('');
const [tag, setTag] = useState<string[]>(client.tags);
const [nuovoTag, setNuovoTag] = useState('');
const [promemoria, setPromemoria] = useState(true);
const [reportSemestrale, setReportSemestrale] = useState(false);
const ultima = measurements[measurements.length - 1];
const stress = ultima?.stress ?? client.stress;
const recovery = ultima?.recovery ?? 0;
const balance = ultima?.balance ?? 0;
const energy = ultima?.energy ?? 0;
const datiGrafico = useMemo(
() => (periodo === 'all' ? measurements : measurements.slice(-Number(periodo))),
[measurements, periodo]
);
const serieAttive = METRICHE.filter((m) => metriche.includes(m.key));
const ultimeTre = [...measurements].slice(-3).reverse();
const storico = [...measurements].reverse();
const toggleMetrica = (key: string) =>
setMetriche((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
const aggiungiNota = () => {
const testo = nuovaNota.trim();
if (!testo) return;
setNoteCliente((prev) => [{ clientId: client.id, text: testo, when: client.lastMeasurement }, ...prev]);
setNuovaNota('');
};
const invia = () => {
const testo = bozza.trim();
if (!testo) return;
setMessaggi((prev) => [...prev, { from: 'professionista', text: testo, when: 'ora' }]);
setBozza('');
};
const aggiungiTag = () => {
const t = nuovoTag.trim();
if (!t || tag.includes(t)) return;
setTag((prev) => [...prev, t]);
setNuovoTag('');
};
return (
<div className="si-stack">
<div className="si-client-head">
<span className="si-avatar si-avatar--lg">{client.initials}</span>
<div>
<h1>{client.name}</h1>
<p className="si-muted si-small">
{client.age} anni · {client.sex} · in carico dal {client.since}
</p>
</div>
</div>
<div className="si-grid si-grid--kpi">
{[
{ label: 'Stress', value: stress, note: qualitativa(stress) },
{ label: 'Recupero', value: recovery, note: qualitativa(recovery) },
{ label: 'Misurazioni', value: measurements.length, note: 'totali' },
{ label: 'In carico da', value: client.since, note: 'data iscrizione' },
].map((k) => (
<div className="si-card" key={k.label}>
<p className="si-kpi__label">{k.label}</p>
<p className="si-kpi__value">{k.value}</p>
<p className="si-muted si-small">{k.note}</p>
</div>
))}
</div>
<div className="si-tabs" role="tablist">
{TABS.map((t) => (
<button
key={t.key} type="button" role="tab" className="si-tabs__btn"
aria-selected={t.key === tab} onClick={() => setTab(t.key)}
>
{t.label}
</button>
))}
</div>
{tab === 'panoramica' && (
<div className="si-stack">
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Stato attuale</h2></div>
<div className="si-gauges">
<Gauge value={stress} label="Stress" />
<Gauge value={recovery} label="Recupero" />
<Gauge value={balance} label="Equilibrio" />
<Gauge value={energy} label="Energia" />
</div>
</section>
<section className="si-card">
<div className="si-card__head">
<h2 className="si-card__title">Andamento clinico</h2>
</div>
<div className="si-chips">
{PERIODI.map((p) => (
<button key={p.key} type="button" className="si-chip" aria-pressed={periodo === p.key} onClick={() => setPeriodo(p.key)}>
{p.label}
</button>
))}
</div>
<div className="si-chips">
{METRICHE.map((m) => (
<button key={m.key} type="button" className="si-chip" aria-pressed={metriche.includes(m.key)} onClick={() => toggleMetrica(m.key)}>
{m.label}
</button>
))}
</div>
<DataChart data={datiGrafico} xKey="date" series={serieAttive} />
</section>
<div className="si-grid si-grid--halves">
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Alert attivi</h2></div>
{alerts.length === 0 ? (
<p className="si-muted si-small">Nessun alert attivo per questo cliente.</p>
) : (
alerts.map((a, i) => (
<div className={`si-alert${a.severity === 'error' ? ' si-alert--error' : ''}`} key={i}>
<span className="si-alert__mark" aria-hidden="true" />
<div>
<p className="si-alert__text">{a.text}</p>
<p className="si-alert__meta">{a.when}</p>
</div>
</div>
))
)}
</section>
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Ultime 3 misurazioni</h2></div>
<ul className="si-list si-list--rows">
{ultimeTre.map((m, i) => (
<li key={i}>
<span>
<strong>{m.date}</strong>
<span className="si-muted"> · {m.type} · stress {m.stress}</span>
</span>
<span className={`si-badge si-badge--${livello(m.stress)}`}>{qualitativa(m.stress)}</span>
</li>
))}
</ul>
</section>
</div>
<section className="si-insight">
<p className="si-insight__title">Sintesi</p>
<p>
{stress >= 70
? `${client.name} mostra un livello di stress elevato nelle ultime misurazioni, con recupero notturno ridotto. Consigliata una revisione del carico settimanale e un lavoro sulla respirazione.`
: `${client.name} mantiene un equilibrio complessivamente stabile. Continuare il monitoraggio periodico e mantenere le abitudini di recupero attuali.`}
</p>
</section>
</div>
)}
{tab === 'misurazioni' && (
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Storico misurazioni</h2></div>
<div className="si-scroll-x">
<table className="si-table">
<thead>
<tr><th>Data</th><th>Tipo</th><th>Stress</th><th>Recupero</th><th>Equilibrio</th><th>Energia</th><th>Durata</th></tr>
</thead>
<tbody>
{storico.map((m, i) => (
<tr key={i}>
<td className="si-table__num">{m.date}</td>
<td>{m.type}</td>
<td className="si-table__num">{m.stress}</td>
<td className="si-table__num">{m.recovery}</td>
<td className="si-table__num">{m.balance}</td>
<td className="si-table__num">{m.energy}</td>
<td className="si-table__num">{m.durationMin} min</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{tab === 'analytics' && (
<div className="si-stack">
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Trend dei quattro parametri</h2></div>
<DataChart data={measurements} xKey="date" series={METRICHE} />
</section>
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Stress e recupero, ultime misurazioni</h2></div>
<DataChart type="bar" data={ultimeTre} xKey="date" series={[METRICHE[0], METRICHE[1]]} />
</section>
</div>
)}
{tab === 'report' && (
<section className="si-card">
<div className="si-card__head">
<h2 className="si-card__title">Report generati</h2>
<button type="button" className="si-btn">Genera report</button>
</div>
<ul className="si-list si-list--rows">
{REPORT.map((r) => (
<li key={r.date}>
<span><span className="si-muted si-table__num">{r.date}</span> · {r.label}</span>
<button type="button" className="si-btn">Scarica</button>
</li>
))}
</ul>
</section>
)}
{tab === 'note' && (
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Note cliente</h2></div>
<div className="si-stack">
<label className="si-field">
Aggiungi nota
<textarea
rows={3} value={nuovaNota} onChange={(e) => setNuovaNota(e.target.value)}
placeholder="Scrivi una nota clinica su questo cliente…"
/>
</label>
<div><button type="button" className="si-btn si-btn--solid" onClick={aggiungiNota}>Aggiungi nota</button></div>
{noteCliente.length === 0 ? (
<p className="si-muted si-small">Nessuna nota registrata per questo cliente.</p>
) : (
<ul className="si-list">
{noteCliente.map((n, i) => (
<li className="si-note-card" key={i}>
<p className="si-note__meta">{n.when}</p>
<p>{n.text}</p>
</li>
))}
</ul>
)}
</div>
</section>
)}
{tab === 'messaggi' && (
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Messaggi</h2></div>
<ul className="si-chat">
{messaggi.map((m, i) => (
<li key={i} className={`si-chat__row si-chat__row--${m.from}`}>
<div className="si-chat__bubble">
<p>{m.text}</p>
<p className="si-chat__when">{m.when}</p>
</div>
</li>
))}
</ul>
<div className="si-chat__compose">
<label className="si-field si-field--inline">
<span className="si-visually-hidden">Messaggio</span>
<input type="text" value={bozza} onChange={(e) => setBozza(e.target.value)} placeholder="Scrivi un messaggio…" />
</label>
<button type="button" className="si-btn si-btn--solid" onClick={invia}>Invia</button>
</div>
</section>
)}
{tab === 'impostazioni' && (
<div className="si-stack">
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Tag cliente</h2></div>
<div className="si-tag-list">
{tag.map((t) => (
<span className="si-tag si-tag--removable" key={t}>
{t}
<button type="button" aria-label={`Rimuovi tag ${t}`} onClick={() => setTag((prev) => prev.filter((x) => x !== t))}></button>
</span>
))}
</div>
<div className="si-chat__compose">
<label className="si-field si-field--inline">
<span className="si-visually-hidden">Nuovo tag</span>
<input type="text" value={nuovoTag} onChange={(e) => setNuovoTag(e.target.value)} placeholder="Nuovo tag…" />
</label>
<button type="button" className="si-btn" onClick={aggiungiTag}>Aggiungi</button>
</div>
</section>
<section className="si-card">
<div className="si-card__head"><h2 className="si-card__title">Preferenze</h2></div>
<ul className="si-list si-list--rows">
<li>
<span>Promemoria automatici di misurazione</span>
<input type="checkbox" checked={promemoria} onChange={(e) => setPromemoria(e.target.checked)} />
</li>
<li>
<span>Invio automatico del report semestrale</span>
<input type="checkbox" checked={reportSemestrale} onChange={(e) => setReportSemestrale(e.target.checked)} />
</li>
</ul>
</section>
</div>
)}
</div>
);
}