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:
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import type { Alert } from '../../lib/stress-index/data';
|
||||
interface Props { alert: Alert }
|
||||
const { alert } = Astro.props;
|
||||
---
|
||||
<div class:list={['si-alert', alert.severity === 'error' && 'si-alert--error']}>
|
||||
<span class="si-alert__mark" aria-hidden="true"></span>
|
||||
<div>
|
||||
<p class="si-alert__text">{alert.text}</p>
|
||||
<p class="si-alert__meta">{alert.clientName} · {alert.when}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
// Riquadro base della dashboard: titolo opzionale e, a destra, un'azione (di solito un link).
|
||||
interface Props { title?: string; actionLabel?: string; actionHref?: string; class?: string }
|
||||
const { title, actionLabel, actionHref, class: cls } = Astro.props;
|
||||
---
|
||||
<section class:list={['si-card', cls]}>
|
||||
{(title || actionLabel) && (
|
||||
<div class="si-card__head">
|
||||
{title && <h2 class="si-card__title">{title}</h2>}
|
||||
{actionLabel && actionHref && <a class="si-card__action" href={actionHref}>{actionLabel}</a>}
|
||||
</div>
|
||||
)}
|
||||
<slot />
|
||||
</section>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
// Indicatore sintetico: valore grande, etichetta e variazione con verso.
|
||||
interface Props { label: string; value: string | number; delta?: string; trend?: 'up' | 'down' }
|
||||
const { label, value, delta, trend = 'up' } = Astro.props;
|
||||
---
|
||||
<div class="si-card">
|
||||
<p class="si-kpi__label">{label}</p>
|
||||
<p class="si-kpi__value">{value}</p>
|
||||
{delta && (
|
||||
<p class:list={['si-kpi__delta', trend === 'down' ? 'si-kpi__delta--down' : 'si-kpi__delta--up']}>
|
||||
{trend === 'down' ? '▼' : '▲'} {delta}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
// Coppie etichetta/valore incolonnate, usate nei riquadri di riepilogo.
|
||||
interface Props { items: { label: string; value: string | number }[] }
|
||||
const { items } = Astro.props;
|
||||
---
|
||||
<dl class="si-list si-list--defs">
|
||||
{items.map((i) => (
|
||||
<div class="si-list__row">
|
||||
<dt class="si-muted">{i.label}</dt>
|
||||
<dd class="si-table__num">{i.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -0,0 +1,56 @@
|
||||
// Andamento medio dello studio, con selettore di periodo. L'aggregazione media stress e
|
||||
// recupero di tutti i clienti sugli ultimi N rilievi settimanali.
|
||||
import { useState } from 'react';
|
||||
import DataChart from './DataChart';
|
||||
import type { Measurement } from '../../../lib/stress-index/data';
|
||||
|
||||
const PERIODS = [
|
||||
{ key: '30g', label: '30 giorni', points: 5 },
|
||||
{ key: '90g', label: '90 giorni', points: 13 },
|
||||
{ key: '6m', label: '6 mesi', points: 20 },
|
||||
{ key: 'tutto', label: 'Tutto', points: 20 },
|
||||
] as const;
|
||||
|
||||
interface Props { measurementsByClient: Record<string, Measurement[]> }
|
||||
|
||||
function aggregate(byClient: Record<string, Measurement[]>, points: number) {
|
||||
const out: { date: string; stress: number; recovery: number }[] = [];
|
||||
for (let fromEnd = points; fromEnd >= 1; fromEnd--) {
|
||||
let stress = 0, recovery = 0, count = 0, date = '';
|
||||
for (const series of Object.values(byClient)) {
|
||||
const point = series[series.length - fromEnd];
|
||||
if (!point) continue;
|
||||
stress += point.stress;
|
||||
recovery += point.recovery;
|
||||
count += 1;
|
||||
date = point.date;
|
||||
}
|
||||
if (count) out.push({ date, stress: Math.round(stress / count), recovery: Math.round(recovery / count) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function AndamentoChart({ measurementsByClient }: Props) {
|
||||
const [period, setPeriod] = useState<string>('90g');
|
||||
const active = PERIODS.find((p) => p.key === period) ?? PERIODS[1];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="si-chips">
|
||||
{PERIODS.map((p) => (
|
||||
<button key={p.key} type="button" className="si-chip" aria-pressed={p.key === period} onClick={() => setPeriod(p.key)}>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<DataChart
|
||||
data={aggregate(measurementsByClient, active.points)}
|
||||
xKey="date"
|
||||
series={[
|
||||
{ key: 'stress', label: 'Stress medio' },
|
||||
{ key: 'recovery', label: 'Recupero medio' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Grafico generico della piattaforma (isola React: recharts ha bisogno del DOM).
|
||||
// I colori sono quelli del sito, non quelli del prototipo originale.
|
||||
import {
|
||||
ResponsiveContainer, LineChart, BarChart, Line, Bar, CartesianGrid, XAxis, YAxis, Tooltip, Legend,
|
||||
} from 'recharts';
|
||||
|
||||
export interface ChartSeries { key: string; label: string; color?: string }
|
||||
|
||||
interface Props {
|
||||
type?: 'line' | 'bar';
|
||||
data: Record<string, string | number>[];
|
||||
xKey: string;
|
||||
series: ChartSeries[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const COLORS = ['#9c8b70', '#3a2929', '#6f8f6a', '#c08a3e', '#b05a4e'];
|
||||
const AXIS = '#8a8a8a';
|
||||
|
||||
export default function DataChart({ type = 'line', data, xKey, series, height = 260 }: Props) {
|
||||
if (!data.length) return <p className="si-chart__empty">Nessun dato nel periodo selezionato.</p>;
|
||||
const Chart = type === 'bar' ? BarChart : LineChart;
|
||||
|
||||
return (
|
||||
<div className="si-chart" style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<Chart data={data} margin={{ top: 6, right: 8, bottom: 0, left: -18 }}>
|
||||
<CartesianGrid stroke="#e4dfd7" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey={xKey} stroke={AXIS} fontSize={11} tickLine={false} />
|
||||
<YAxis stroke={AXIS} fontSize={11} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ border: '1px solid #e4dfd7', borderRadius: 0, fontSize: 12 }}
|
||||
labelStyle={{ color: '#2b2b2b', fontWeight: 600 }}
|
||||
/>
|
||||
{series.length > 1 && <Legend iconType="plainline" wrapperStyle={{ fontSize: 12 }} />}
|
||||
{series.map((s, i) =>
|
||||
type === 'bar' ? (
|
||||
<Bar key={s.key} dataKey={s.key} name={s.label} fill={s.color ?? COLORS[i % COLORS.length]} />
|
||||
) : (
|
||||
<Line
|
||||
key={s.key} type="monotone" dataKey={s.key} name={s.label} dot={false} strokeWidth={2.2}
|
||||
stroke={s.color ?? COLORS[i % COLORS.length]}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Chart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user