c4eb598a3a
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>
388 lines
16 KiB
TypeScript
388 lines
16 KiB
TypeScript
// 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>
|
|
);
|
|
}
|