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:
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import { site } from '../data/site';
|
||||
import { piattaforme } from '../data/piattaforme';
|
||||
import { t, contentImageUrl } from '../lib/content';
|
||||
import { landingFor } from '../lib/auth';
|
||||
interface Props {
|
||||
@@ -11,8 +12,9 @@ const { user, canTags, showTags } = Astro.props;
|
||||
const path = Astro.url.pathname;
|
||||
const loginHref = `/login?next=${encodeURIComponent(path)}`;
|
||||
const areaHref = user ? landingFor(user.role) : '/admin';
|
||||
// La sezione Biohacking compare nel menu solo a chi può davvero aprirla.
|
||||
const canBiohacking = user?.role === 'campus' || user?.role === 'admin';
|
||||
// Le piattaforme riservate compaiono nel menu solo a chi può davvero aprirle.
|
||||
const canPiattaforme = user?.role === 'piattaforme' || user?.role === 'admin';
|
||||
const inPiattaforme = piattaforme.some((p) => path === p.href || path.startsWith(`${p.href}/`));
|
||||
---
|
||||
<header class="hdr" id="site-header">
|
||||
<div class="container hdr__in">
|
||||
@@ -23,8 +25,15 @@ const canBiohacking = user?.role === 'campus' || user?.role === 'admin';
|
||||
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a>
|
||||
)
|
||||
))}
|
||||
{canBiohacking && (
|
||||
<a href="/campus" class:list={['hdr__link', 'hdr__link--bio', { 'is-active': path.startsWith('/campus') }]}>Biohacking</a>
|
||||
{canPiattaforme && (
|
||||
<details class="plat">
|
||||
<summary class:list={['hdr__link', 'hdr__link--bio', 'plat__btn', { 'is-active': inPiattaforme }]}>Piattaforme ▾</summary>
|
||||
<div class="plat__panel">
|
||||
{piattaforme.map((p) => (
|
||||
<a href={p.href} class:list={['plat__item', { 'is-active': path === p.href || path.startsWith(`${p.href}/`) }]}>{p.label}</a>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</nav>
|
||||
<div class="hdr__user">
|
||||
@@ -57,6 +66,17 @@ const canBiohacking = user?.role === 'campus' || user?.role === 'admin';
|
||||
.hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); transition: color .3s ease; }
|
||||
.hdr__link:hover, .hdr__link.is-active { color: var(--c-accent-dark); }
|
||||
.hdr__link--bio { color: var(--c-accent-dark); }
|
||||
/* Menu Piattaforme: su desktop è un pannello che scende dalla voce, in mobile —
|
||||
dove la nav è già una colonna — le voci si aprono in linea sotto il titolo. */
|
||||
.plat { position: relative; }
|
||||
.plat__btn { cursor: pointer; list-style: none; }
|
||||
.plat__btn::-webkit-details-marker { display: none; }
|
||||
.plat__panel {
|
||||
position: absolute; left: 0; top: calc(100% + 14px); background: #fff; min-width: 210px;
|
||||
box-shadow: 0 8px 22px rgba(0,0,0,.12); display: flex; flex-direction: column; padding: 8px 0; z-index: 200;
|
||||
}
|
||||
.plat__item { padding: 10px 16px; font-size: .8rem; text-decoration: none; color: var(--c-heading); }
|
||||
.plat__item:hover, .plat__item.is-active { background: #f4f2ef; color: var(--c-accent-dark); }
|
||||
.hdr__burger { display: none; background: none; border: 0; cursor: pointer; padding: 8px; }
|
||||
.hdr__burger span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; }
|
||||
@media (max-width: 991px) {
|
||||
@@ -64,6 +84,8 @@ const canBiohacking = user?.role === 'campus' || user?.role === 'admin';
|
||||
.hdr__nav.is-open { display: flex; }
|
||||
.hdr__link { padding: 12px 0; }
|
||||
.hdr__burger { display: block; }
|
||||
.plat__panel { position: static; box-shadow: none; padding: 0 0 8px; min-width: 0; }
|
||||
.plat__item { padding: 10px 0 10px 14px; }
|
||||
}
|
||||
.hdr__user { display: flex; align-items: center; }
|
||||
.hdr__login { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
|
||||
@@ -86,6 +108,15 @@ const canBiohacking = user?.role === 'campus' || user?.role === 'admin';
|
||||
toggle.setAttribute('aria-expanded', String(open));
|
||||
});
|
||||
|
||||
// Il pannello Piattaforme è un <details>: da solo resterebbe aperto anche cliccando altrove.
|
||||
const plat = document.querySelector<HTMLDetailsElement>('.plat');
|
||||
if (plat) {
|
||||
document.addEventListener('click', (e) => {
|
||||
if (plat.open && !plat.contains(e.target as Node)) plat.open = false;
|
||||
});
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') plat.open = false; });
|
||||
}
|
||||
|
||||
const tagsBtn = document.getElementById('toggle-tags');
|
||||
if (tagsBtn) {
|
||||
const on = tagsBtn.dataset.on === '1';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Piattaforme riservate: sezioni applicative accessibili solo ai ruoli `piattaforme` e `admin`.
|
||||
// Elenco unico usato dal menu dell'header e dalla pagina indice /piattaforme, così aggiungerne
|
||||
// una in futuro è una riga sola.
|
||||
export interface Piattaforma {
|
||||
slug: string;
|
||||
label: string;
|
||||
href: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const piattaforme: Piattaforma[] = [
|
||||
{
|
||||
slug: 'campus',
|
||||
label: 'Biohacking Campus',
|
||||
href: '/campus',
|
||||
description: 'Le guide di studio del corso: aree, capitoli e lezioni con le slide.',
|
||||
},
|
||||
{
|
||||
slug: 'stress-index',
|
||||
label: 'Stress Index',
|
||||
href: '/piattaforme/stress-index',
|
||||
description: 'Monitoraggio HRV di stress e recupero: clienti, andamenti e alert.',
|
||||
},
|
||||
];
|
||||
@@ -37,7 +37,7 @@ const user = Astro.locals.user;
|
||||
<a href="/admin">Articoli</a>
|
||||
{(user.role === 'superuser' || user.role === 'admin') && <a href="/admin/content">Contenuti</a>}
|
||||
{user.role === 'admin' && <a href="/admin/users">Utenti</a>}
|
||||
{user.role === 'admin' && <a href="/campus">Biohacking</a>}
|
||||
{user.role === 'admin' && <a href="/piattaforme">Piattaforme</a>}
|
||||
<a href="/admin/new">Nuovo</a>
|
||||
<a href="/admin/logout">Esci</a>
|
||||
<a href="/blog" target="_blank">Vedi sito ↗</a>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
// Layout della piattaforma Stress Index. Come il Campus: token, font e palette del sito,
|
||||
// senza Header e Footer pubblici — la navigazione è tutta interna alla piattaforma.
|
||||
import '@fontsource/montserrat/400.css';
|
||||
import '@fontsource/montserrat/500.css';
|
||||
import '@fontsource/montserrat/600.css';
|
||||
import '@fontsource/open-sans/400.css';
|
||||
import '@fontsource/open-sans/600.css';
|
||||
import '../styles/global.css';
|
||||
import '../styles/stress-index.css';
|
||||
import { professional } from '../lib/stress-index/data';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
/** Voci di percorso mostrate nella topbar, dopo "Stress Index". */
|
||||
crumbs?: string[];
|
||||
}
|
||||
const { title, crumbs = [] } = Astro.props;
|
||||
const path = Astro.url.pathname;
|
||||
const isAdmin = Astro.locals.user?.role === 'admin';
|
||||
|
||||
const BASE = '/piattaforme/stress-index';
|
||||
const NAV = [
|
||||
{ href: BASE, label: 'Oggi' },
|
||||
{ href: `${BASE}/clienti`, label: 'Clienti' },
|
||||
{ href: `${BASE}/analytics`, label: 'Analytics' },
|
||||
{ href: `${BASE}/sport`, label: 'Sport', badge: 'Pro' },
|
||||
{ href: `${BASE}/sport/team-live`, label: 'Team live' },
|
||||
{ href: `${BASE}/impostazioni`, label: 'Impostazioni' },
|
||||
{ href: `${BASE}/organizzazione`, label: 'Organizzazione' },
|
||||
];
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title} — Stress Index InsanityLab</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
</head>
|
||||
<body class="si">
|
||||
<input type="checkbox" id="si-menu" class="si-menu-toggle" hidden />
|
||||
<label for="si-menu" class="si-menu-btn" aria-label="Apri il menu">☰ Menu</label>
|
||||
|
||||
<aside class="si-side">
|
||||
<a class="si-brand" href={BASE}>
|
||||
<span class="eyebrow">InsanityLab</span>
|
||||
Stress Index
|
||||
</a>
|
||||
<a class="si-home" href="/piattaforme">← Piattaforme</a>
|
||||
|
||||
<nav class="si-nav">
|
||||
{NAV.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
class:list={['si-nav__link', { 'is-active': path === item.href }]}
|
||||
>
|
||||
{item.label}
|
||||
{item.badge && <span class="si-nav__badge">{item.badge}</span>}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div class="si-foot">
|
||||
<span class="si-foot__role">{professional.role}</span>
|
||||
{isAdmin && <a href="/admin/users">Gestione utenti</a>}
|
||||
<a href="/admin/logout">Esci</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="si-body">
|
||||
<header class="si-top">
|
||||
<p class="si-top__crumbs">
|
||||
<strong>Stress Index</strong>
|
||||
{crumbs.map((c) => <span> / {c}</span>)}
|
||||
</p>
|
||||
<p class="si-top__studio">{professional.studio}</p>
|
||||
</header>
|
||||
<main class="si-main"><slot /></main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+9
-6
@@ -5,7 +5,7 @@ import { randomBytes } from 'node:crypto';
|
||||
export const SESSION_COOKIE = 'session';
|
||||
const SESSION_DAYS = 7;
|
||||
|
||||
export type Role = 'admin' | 'superuser' | 'user' | 'campus';
|
||||
export type Role = 'admin' | 'superuser' | 'user' | 'piattaforme';
|
||||
|
||||
export function hashPassword(plain: string): string {
|
||||
return bcrypt.hashSync(plain, 12);
|
||||
@@ -58,7 +58,10 @@ const RULES: [RegExp, Role[]][] = [
|
||||
[/^\/api\/admin\/users(\/|$)/, ['admin']],
|
||||
[/^\/admin\/content(\/|$)/, ['admin', 'superuser']],
|
||||
[/^\/api\/admin\/content(\/|$)/, ['admin', 'superuser']],
|
||||
[/^\/campus(\/|$)/, ['admin', 'campus']],
|
||||
// Le piattaforme riservate: il Campus Biohacking (contenuti) e l'indice/dashboard
|
||||
// sotto /piattaforme. Stesso ruolo per entrambe.
|
||||
[/^\/campus(\/|$)/, ['admin', 'piattaforme']],
|
||||
[/^\/piattaforme(\/|$)/, ['admin', 'piattaforme']],
|
||||
];
|
||||
|
||||
export function canAccessAdminPath(role: string, pathname: string): boolean {
|
||||
@@ -66,8 +69,8 @@ export function canAccessAdminPath(role: string, pathname: string): boolean {
|
||||
for (const [re, roles] of RULES) {
|
||||
if (re.test(pathname)) return roles.includes(role as Role);
|
||||
}
|
||||
// campus vede solo la sua sezione (coperta da RULES) e il logout
|
||||
if (role === 'campus') return pathname === '/admin/logout';
|
||||
// piattaforme vede solo le sue sezioni (coperte da RULES) e il logout
|
||||
if (role === 'piattaforme') return pathname === '/admin/logout';
|
||||
return true; // blog, upload, logout, showtags: tutti i loggati
|
||||
}
|
||||
|
||||
@@ -108,11 +111,11 @@ export function randomPassword(): string {
|
||||
}
|
||||
|
||||
export function isRole(v: unknown): v is Role {
|
||||
return v === 'admin' || v === 'superuser' || v === 'user' || v === 'campus';
|
||||
return v === 'admin' || v === 'superuser' || v === 'user' || v === 'piattaforme';
|
||||
}
|
||||
|
||||
export function landingFor(role: string): string {
|
||||
if (role === 'superuser') return '/admin/content';
|
||||
if (role === 'campus') return '/campus';
|
||||
if (role === 'piattaforme') return '/piattaforme';
|
||||
return '/admin';
|
||||
}
|
||||
|
||||
@@ -221,6 +221,11 @@ export function createDb(path?: string): Database.Database {
|
||||
const updAbout = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value LIKE ?');
|
||||
const updAboutTx = db.transaction(() => { for (const [tag, frag, newV] of ABOUT_02) updAbout.run(newV, tag, frag); });
|
||||
updAboutTx();
|
||||
// Il ruolo `campus` diventa `piattaforme`: non dà più accesso alla sola sezione Biohacking
|
||||
// ma a tutte le piattaforme riservate (Campus, Stress Index). Rinomina una tantum delle
|
||||
// righe esistenti; il vincolo sui valori ammessi vive in auth.ts (isRole), non nello schema.
|
||||
db.prepare("UPDATE users SET role = 'piattaforme' WHERE role = 'campus'").run();
|
||||
|
||||
// Backfill: righe pre-esistenti (già presenti prima dell'introduzione del guid) restano
|
||||
// ignorate dall'INSERT OR IGNORE e hanno guid nullo → assegna un UUID a ciascuna.
|
||||
const missing = db.prepare(`SELECT tag FROM content_blocks WHERE guid IS NULL OR guid = ''`).all() as { tag: string }[];
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// Dati della piattaforma Stress Index. Sono ancora i dati dimostrativi del prototipo
|
||||
// (nessun backend HRV collegato): stanno tutti qui, così quando arriverà la sorgente vera
|
||||
// basterà sostituire queste costanti con le query, lasciando intatte pagine e componenti.
|
||||
|
||||
export interface Client {
|
||||
id: string;
|
||||
name: string;
|
||||
initials: string;
|
||||
age: number;
|
||||
sex: 'M' | 'F';
|
||||
tags: string[];
|
||||
email: string;
|
||||
since: string;
|
||||
lastMeasurement: string;
|
||||
stress: number;
|
||||
alerts: number;
|
||||
}
|
||||
|
||||
export const clients: Client[] = [
|
||||
{ id: 'c1', name: 'L. Verdi', initials: 'LV', age: 34, sex: 'F', tags: ['ansia', 'sonno'], email: 'l.verdi@esempio.it', since: '2024-02-10', lastMeasurement: '2026-07-10', stress: 62, alerts: 1 },
|
||||
{ id: 'c2', name: 'A. Neri', initials: 'AN', age: 45, sex: 'M', tags: ['sport', 'recupero'], email: 'a.neri@esempio.it', since: '2023-11-05', lastMeasurement: '2026-07-11', stress: 38, alerts: 0 },
|
||||
{ id: 'c3', name: 'G. Rossi', initials: 'GR', age: 29, sex: 'M', tags: ['lavoro', 'stress'], email: 'g.rossi@esempio.it', since: '2024-05-20', lastMeasurement: '2026-07-09', stress: 78, alerts: 2 },
|
||||
{ id: 'c4', name: 'S. Ferrari', initials: 'SF', age: 52, sex: 'F', tags: ['menopausa', 'sonno'], email: 's.ferrari@esempio.it', since: '2023-08-14', lastMeasurement: '2026-07-12', stress: 55, alerts: 0 },
|
||||
{ id: 'c5', name: 'P. Colombo', initials: 'PC', age: 40, sex: 'M', tags: ['sport', 'agonismo'], email: 'p.colombo@esempio.it', since: '2024-01-30', lastMeasurement: '2026-07-08', stress: 44, alerts: 0 },
|
||||
{ id: 'c6', name: 'E. Ricci', initials: 'ER', age: 61, sex: 'F', tags: ['cronico', 'riabilitazione'], email: 'e.ricci@esempio.it', since: '2022-09-01', lastMeasurement: '2026-07-11', stress: 70, alerts: 1 },
|
||||
{ id: 'c7', name: 'D. Marino', initials: 'DM', age: 33, sex: 'M', tags: ['lavoro', 'burnout'], email: 'd.marino@esempio.it', since: '2024-03-18', lastMeasurement: '2026-07-07', stress: 82, alerts: 2 },
|
||||
{ id: 'c8', name: 'C. Greco', initials: 'CG', age: 27, sex: 'F', tags: ['sport', 'studentessa'], email: 'c.greco@esempio.it', since: '2024-06-02', lastMeasurement: '2026-07-12', stress: 30, alerts: 0 },
|
||||
{ id: 'c9', name: 'F. Bruno', initials: 'FB', age: 48, sex: 'M', tags: ['ipertensione', 'sonno'], email: 'f.bruno@esempio.it', since: '2023-04-25', lastMeasurement: '2026-07-06', stress: 66, alerts: 1 },
|
||||
{ id: 'c10', name: 'V. Costa', initials: 'VC', age: 36, sex: 'F', tags: ['ansia', 'lavoro'], email: 'v.costa@esempio.it', since: '2024-07-01', lastMeasurement: '2026-07-10', stress: 58, alerts: 1 },
|
||||
];
|
||||
|
||||
export interface Measurement {
|
||||
date: string;
|
||||
stress: number;
|
||||
recovery: number;
|
||||
balance: number;
|
||||
energy: number;
|
||||
type: string;
|
||||
durationMin: number;
|
||||
}
|
||||
|
||||
const clamp = (n: number) => Math.max(0, Math.min(100, Math.round(n)));
|
||||
const TYPES = ['Riposo', 'Lavoro', 'Sport', 'Sonno'];
|
||||
|
||||
// Serie settimanale deterministica: l'oscillazione deriva da id + indice, così i grafici
|
||||
// sono sempre uguali fra server e client (nessun Math.random che sfarfalla all'idratazione).
|
||||
function buildSeries(clientId: string, baseStress: number, endDate: string, points: number): Measurement[] {
|
||||
const end = new Date(endDate);
|
||||
const series: Measurement[] = [];
|
||||
for (let i = points - 1; i >= 0; i--) {
|
||||
const d = new Date(end);
|
||||
d.setDate(d.getDate() - i * 7);
|
||||
const seed = clientId.charCodeAt(clientId.length - 1) + i * 13;
|
||||
const wobble = Math.sin(seed) * 12;
|
||||
const stress = clamp(baseStress + wobble);
|
||||
series.push({
|
||||
date: d.toISOString().slice(0, 10),
|
||||
stress,
|
||||
recovery: clamp(100 - stress + Math.cos(seed) * 8),
|
||||
balance: clamp(70 - Math.abs(wobble) + Math.sin(seed * 2) * 6),
|
||||
energy: clamp(65 - (stress - 50) * 0.4 + Math.cos(seed * 1.5) * 7),
|
||||
type: TYPES[i % TYPES.length],
|
||||
durationMin: 15 + ((i * 5) % 40),
|
||||
});
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
export const measurementsByClient: Record<string, Measurement[]> = {
|
||||
c1: buildSeries('c1', 62, '2026-07-10', 16),
|
||||
c2: buildSeries('c2', 38, '2026-07-11', 14),
|
||||
c3: buildSeries('c3', 78, '2026-07-09', 18),
|
||||
c4: buildSeries('c4', 55, '2026-07-12', 12),
|
||||
c5: buildSeries('c5', 44, '2026-07-08', 15),
|
||||
c6: buildSeries('c6', 70, '2026-07-11', 20),
|
||||
c7: buildSeries('c7', 82, '2026-07-07', 17),
|
||||
c8: buildSeries('c8', 30, '2026-07-12', 10),
|
||||
c9: buildSeries('c9', 66, '2026-07-06', 13),
|
||||
c10: buildSeries('c10', 58, '2026-07-10', 11),
|
||||
};
|
||||
|
||||
export interface Alert {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
text: string;
|
||||
severity: 'warn' | 'error';
|
||||
when: string;
|
||||
}
|
||||
|
||||
export const alerts: Alert[] = [
|
||||
{ clientId: 'c7', clientName: 'D. Marino', text: 'Stress elevato per 3 misurazioni consecutive', severity: 'error', when: '2026-07-07' },
|
||||
{ clientId: 'c3', clientName: 'G. Rossi', text: 'Recupero notturno in calo', severity: 'warn', when: '2026-07-09' },
|
||||
{ clientId: 'c3', clientName: 'G. Rossi', text: 'Bilancio simpatico/parasimpatico sbilanciato', severity: 'warn', when: '2026-07-05' },
|
||||
{ clientId: 'c1', clientName: 'L. Verdi', text: 'Qualità del sonno sotto la soglia attesa', severity: 'warn', when: '2026-07-10' },
|
||||
{ clientId: 'c6', clientName: 'E. Ricci', text: 'Andamento stress in peggioramento nelle ultime 4 settimane', severity: 'warn', when: '2026-07-11' },
|
||||
{ clientId: 'c9', clientName: 'F. Bruno', text: 'Nessuna misurazione da oltre 7 giorni', severity: 'warn', when: '2026-07-13' },
|
||||
{ clientId: 'c10', clientName: 'V. Costa', text: 'Picco di stress rilevato dopo sessione di lavoro prolungata', severity: 'warn', when: '2026-07-10' },
|
||||
{ clientId: 'c7', clientName: 'D. Marino', text: 'Energia media sotto il 30% da due settimane', severity: 'error', when: '2026-07-01' },
|
||||
];
|
||||
|
||||
export interface Note {
|
||||
clientId?: string;
|
||||
text: string;
|
||||
when: string;
|
||||
}
|
||||
|
||||
export const notes: Note[] = [
|
||||
{ clientId: 'c1', text: 'Consigliata routine di respirazione serale, rivalutare tra 2 settimane.', when: '2026-07-10' },
|
||||
{ clientId: 'c3', text: 'Segnalato carico lavorativo elevato, proposto colloquio di follow-up.', when: '2026-07-09' },
|
||||
{ clientId: 'c7', text: 'Situazione di burnout in peggioramento, valutare invio a specialista.', when: '2026-07-07' },
|
||||
{ text: 'Aggiornamento protocollo misurazioni: aggiunta soglia energia minima.', when: '2026-07-05' },
|
||||
{ clientId: 'c6', text: 'Programma di riabilitazione in corso, buoni progressi sul recupero.', when: '2026-07-11' },
|
||||
];
|
||||
|
||||
export const studioKpis = {
|
||||
totalClients: 10,
|
||||
avgStress: 58,
|
||||
activeAlerts: 8,
|
||||
measurementsThisMonth: 46,
|
||||
retentionRate: 92,
|
||||
};
|
||||
|
||||
export const scoreDistribution = [
|
||||
{ range: '0-20', count: 0 },
|
||||
{ range: '21-40', count: 2 },
|
||||
{ range: '41-60', count: 3 },
|
||||
{ range: '61-80', count: 4 },
|
||||
{ range: '81-100', count: 1 },
|
||||
];
|
||||
|
||||
export const segments = [
|
||||
{ label: 'Sportivi', count: 3, avgStress: 37 },
|
||||
{ label: 'Stress lavorativo', count: 4, avgStress: 69 },
|
||||
{ label: 'Sonno/recupero', count: 3, avgStress: 61 },
|
||||
];
|
||||
|
||||
export const topPerformers = [
|
||||
{ clientId: 'c8', name: 'C. Greco', stress: 30 },
|
||||
{ clientId: 'c2', name: 'A. Neri', stress: 38 },
|
||||
{ clientId: 'c5', name: 'P. Colombo', stress: 44 },
|
||||
];
|
||||
|
||||
export const criticalClients = [
|
||||
{ clientId: 'c7', name: 'D. Marino', stress: 82 },
|
||||
{ clientId: 'c3', name: 'G. Rossi', stress: 78 },
|
||||
{ clientId: 'c6', name: 'E. Ricci', stress: 70 },
|
||||
];
|
||||
|
||||
export const sessions = [
|
||||
{ id: 's1', athleteId: 'c2', date: '2026-07-11', type: 'Corsa', durationMin: 45, load: 62, trimp: 58, hrAvg: 142, dfaA1: 0.72 },
|
||||
{ id: 's2', athleteId: 'c5', date: '2026-07-08', type: 'Ciclismo', durationMin: 90, load: 74, trimp: 96, hrAvg: 138, dfaA1: 0.61 },
|
||||
{ id: 's3', athleteId: 'c8', date: '2026-07-12', type: 'Nuoto', durationMin: 40, load: 50, trimp: 45, hrAvg: 128, dfaA1: 0.8 },
|
||||
{ id: 's4', athleteId: 'c2', date: '2026-07-04', type: 'Pesi', durationMin: 60, load: 68, trimp: 70, hrAvg: 145, dfaA1: 0.55 },
|
||||
{ id: 's5', athleteId: 'c5', date: '2026-07-01', type: 'Corsa', durationMin: 50, load: 58, trimp: 52, hrAvg: 135, dfaA1: 0.75 },
|
||||
];
|
||||
|
||||
export const athletes = [
|
||||
{ clientId: 'c2', name: 'A. Neri', sport: 'Corsa', weeklyLoad: 130 },
|
||||
{ clientId: 'c5', name: 'P. Colombo', sport: 'Ciclismo', weeklyLoad: 164 },
|
||||
{ clientId: 'c8', name: 'C. Greco', sport: 'Nuoto', weeklyLoad: 90 },
|
||||
];
|
||||
|
||||
export const sessionsPerWeek = [
|
||||
{ week: 'S1', count: 4 }, { week: 'S2', count: 5 }, { week: 'S3', count: 3 },
|
||||
{ week: 'S4', count: 6 }, { week: 'S5', count: 4 }, { week: 'S6', count: 5 },
|
||||
{ week: 'S7', count: 2 }, { week: 'S8', count: 5 }, { week: 'S9', count: 6 },
|
||||
{ week: 'S10', count: 4 }, { week: 'S11', count: 5 }, { week: 'S12', count: 3 },
|
||||
];
|
||||
|
||||
// "Oggi" della piattaforma: i dati dimostrativi sono ancorati a questa data, quindi i conteggi
|
||||
// "giorni fa" restano coerenti invece di crescere col passare del tempo reale.
|
||||
export const OGGI = '2026-07-13';
|
||||
|
||||
export function daysSince(date: string, from: string = OGGI): number {
|
||||
return Math.max(0, Math.round((Date.parse(from) - Date.parse(date)) / 86_400_000));
|
||||
}
|
||||
|
||||
export function findClient(id: string): Client | null {
|
||||
return clients.find((c) => c.id === id) ?? null;
|
||||
}
|
||||
|
||||
export const professional = {
|
||||
name: 'Dr. M. Bianchi',
|
||||
role: 'Osteopata',
|
||||
studio: 'Studio Bianchi — Osteopatia e Benessere',
|
||||
email: 'm.bianchi@esempio.it',
|
||||
};
|
||||
+5
-4
@@ -7,7 +7,8 @@ export const onRequest = defineMiddleware((context, next) => {
|
||||
const isProtected =
|
||||
(pathname.startsWith('/admin') && pathname !== '/admin/login') ||
|
||||
pathname.startsWith('/api/admin') ||
|
||||
pathname.startsWith('/campus');
|
||||
pathname.startsWith('/campus') ||
|
||||
pathname.startsWith('/piattaforme');
|
||||
if (!isProtected) return next();
|
||||
|
||||
const token = context.cookies.get(SESSION_COOKIE)?.value;
|
||||
@@ -18,9 +19,9 @@ export const onRequest = defineMiddleware((context, next) => {
|
||||
status: 401, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
// La sezione Biohacking si raggiunge dal sito, quindi passa dal login
|
||||
// pubblico e ci torna dopo l'accesso; il pannello ha il proprio.
|
||||
if (pathname.startsWith('/campus'))
|
||||
// Le piattaforme riservate si raggiungono dal sito, quindi passano dal login
|
||||
// pubblico e ci tornano dopo l'accesso; il pannello ha il proprio.
|
||||
if (pathname.startsWith('/campus') || pathname.startsWith('/piattaforme'))
|
||||
return context.redirect(`/login?next=${encodeURIComponent(pathname)}`);
|
||||
return context.redirect('/admin/login');
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const me = Astro.locals.user!;
|
||||
<select class="afield" name="role">
|
||||
<option value="user">user</option>
|
||||
<option value="superuser">superuser</option>
|
||||
<option value="campus">campus</option>
|
||||
<option value="piattaforme">piattaforme</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button class="abtn" type="submit">Crea</button>
|
||||
@@ -32,7 +32,7 @@ const me = Astro.locals.user!;
|
||||
<td>{u.username}{u.id === me.id && ' (tu)'}</td>
|
||||
<td>
|
||||
<select class="role-sel" data-id={u.id}>
|
||||
{['user', 'superuser', 'campus', 'admin'].map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
|
||||
{['user', 'superuser', 'piattaforme', 'admin'].map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
// Indice delle piattaforme riservate: è la pagina di atterraggio del ruolo `piattaforme`
|
||||
// (vedi landingFor in lib/auth). L'accesso è già filtrato dal middleware.
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { piattaforme } from '../../data/piattaforme';
|
||||
export const prerender = false;
|
||||
---
|
||||
<Base title="Piattaforme" description="Le piattaforme riservate InsanityLab.">
|
||||
<section class="section plat-index">
|
||||
<div class="container">
|
||||
<p class="plat-index__eyebrow">Area riservata</p>
|
||||
<h1>Piattaforme</h1>
|
||||
<p class="plat-index__lead">Gli strumenti riservati agli utenti abilitati.</p>
|
||||
<div class="plat-index__grid">
|
||||
{piattaforme.map((p) => (
|
||||
<a class="plat-card" href={p.href}>
|
||||
<h2 class="plat-card__title">{p.label}</h2>
|
||||
<p class="plat-card__desc">{p.description}</p>
|
||||
<span class="plat-card__cta">Entra</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.plat-index__eyebrow {
|
||||
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em;
|
||||
text-transform: uppercase; color: var(--c-accent-dark); margin: 0 0 .6em;
|
||||
}
|
||||
.plat-index__lead { max-width: 46ch; color: var(--c-text-light); margin-bottom: 46px; }
|
||||
.plat-index__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px; }
|
||||
.plat-card {
|
||||
display: block; text-decoration: none; background: var(--c-bg-alt); padding: 34px 30px 30px;
|
||||
border-top: 3px solid var(--c-accent); transition: transform .3s ease, box-shadow .3s ease;
|
||||
}
|
||||
.plat-card:hover { transform: translateY(-4px); box-shadow: 0 14px 30px rgba(0,0,0,.08); }
|
||||
.plat-card__title { font-size: 1.25rem; margin-bottom: .4em; }
|
||||
.plat-card__desc { color: var(--c-text-light); font-size: .92rem; margin-bottom: 1.4em; }
|
||||
.plat-card__cta {
|
||||
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em;
|
||||
text-transform: uppercase; color: var(--c-accent-dark);
|
||||
}
|
||||
.plat-card__cta::after { content: ' →'; }
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
// Analytics di studio: numeri aggregati sul periodo. Il selettore di periodo è ancora
|
||||
// dimostrativo (i dati sorgente sono un'istantanea sola), come nel prototipo.
|
||||
import StressIndex from '../../../layouts/StressIndex.astro';
|
||||
import Card from '../../../components/stress-index/Card.astro';
|
||||
import Kpi from '../../../components/stress-index/Kpi.astro';
|
||||
import StatList from '../../../components/stress-index/StatList.astro';
|
||||
import DataChart from '../../../components/stress-index/charts/DataChart';
|
||||
import { studioKpis, scoreDistribution, segments, topPerformers, criticalClients } from '../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
---
|
||||
<StressIndex title="Analytics" crumbs={['Analytics']}>
|
||||
<div class="si-head si-head--row">
|
||||
<div>
|
||||
<h1>Analytics</h1>
|
||||
<p>Andamento dello studio nel periodo selezionato</p>
|
||||
</div>
|
||||
<label class="si-field si-field--inline">
|
||||
<span class="si-visually-hidden">Intervallo</span>
|
||||
<select>
|
||||
<option value="7">Ultimi 7 giorni</option>
|
||||
<option value="30" selected>Ultimi 30 giorni</option>
|
||||
<option value="90">Ultimi 90 giorni</option>
|
||||
<option value="365">Ultimo anno</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="si-grid si-grid--kpi">
|
||||
<Kpi label="Clienti attivi" value={studioKpis.totalClients} />
|
||||
<Kpi label="Misurazioni nel periodo" value={studioKpis.measurementsThisMonth} />
|
||||
<Kpi label="Alert attivi" value={studioKpis.activeAlerts} />
|
||||
<Kpi label="Aderenza" value={`${studioKpis.retentionRate}%`} />
|
||||
</div>
|
||||
|
||||
<div class="si-stack si-mt">
|
||||
<Card title="Distribuzione dello score dei clienti">
|
||||
<DataChart client:visible type="bar" data={scoreDistribution} xKey="range" series={[{ key: 'count', label: 'Clienti' }]} />
|
||||
</Card>
|
||||
|
||||
<Card title="Confronto fra segmenti">
|
||||
<div class="si-grid si-grid--halves">
|
||||
{segments.map((s) => (
|
||||
<div class="si-segment">
|
||||
<p class="si-segment__label">{s.label}</p>
|
||||
<p class="si-kpi__value">{s.avgStress}</p>
|
||||
<p class="si-muted si-small">stress medio · {s.count} {s.count === 1 ? 'cliente' : 'clienti'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="si-grid si-grid--halves">
|
||||
<Card title="Andamento migliore">
|
||||
<StatList items={topPerformers.map((c) => ({ label: c.name, value: c.stress }))} />
|
||||
</Card>
|
||||
<Card title="Clienti critici">
|
||||
<StatList items={criticalClients.map((c) => ({ label: c.name, value: c.stress }))} />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</StressIndex>
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
// Scheda del singolo cliente. La pagina raccoglie i dati e li passa all'isola React che
|
||||
// gestisce tab e interazioni.
|
||||
import StressIndex from '../../../../layouts/StressIndex.astro';
|
||||
import ClientDetail from '../../../../components/stress-index/ClientDetail';
|
||||
import { findClient, measurementsByClient, alerts, notes } from '../../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
|
||||
const { id } = Astro.params;
|
||||
const client = findClient(id ?? '');
|
||||
if (!client) return Astro.redirect('/piattaforme/stress-index/clienti');
|
||||
---
|
||||
<StressIndex title={client.name} crumbs={['Clienti', client.name]}>
|
||||
<ClientDetail
|
||||
client:load
|
||||
client={client}
|
||||
measurements={measurementsByClient[client.id] ?? []}
|
||||
alerts={alerts.filter((a) => a.clientId === client.id)}
|
||||
notes={notes.filter((n) => n.clientId === client.id)}
|
||||
/>
|
||||
</StressIndex>
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
// Elenco clienti (CRM). Il filtro per nome/email è puro JS sulle righe già renderizzate:
|
||||
// i clienti sono una decina, non vale un'isola React per cercarli.
|
||||
import StressIndex from '../../../../layouts/StressIndex.astro';
|
||||
import Card from '../../../../components/stress-index/Card.astro';
|
||||
import { clients } from '../../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
|
||||
const livello = (stress: number) => (stress >= 70 ? 'bad' : stress >= 45 ? 'warn' : 'ok');
|
||||
---
|
||||
<StressIndex title="Clienti" crumbs={['Clienti']}>
|
||||
<div class="si-head">
|
||||
<h1>Clienti</h1>
|
||||
<p>{clients.length} in carico</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div class="si-toolbar">
|
||||
<label class="si-field si-field--inline">
|
||||
<span class="si-visually-hidden">Cerca cliente</span>
|
||||
<input type="search" id="si-cerca" placeholder="Cerca per nome o email" autocomplete="off" />
|
||||
</label>
|
||||
<p class="si-muted si-small" id="si-conteggio">{clients.length} risultati</p>
|
||||
</div>
|
||||
|
||||
<table class="si-table" id="si-tabella-clienti">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cliente</th><th>Età</th><th>Tag</th><th>Ultima misurazione</th><th>Stress</th><th>Alert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clients.map((c) => (
|
||||
<tr data-cerca={`${c.name} ${c.email}`.toLowerCase()}>
|
||||
<td>
|
||||
<a class="si-client" href={`/piattaforme/stress-index/clienti/${c.id}`}>
|
||||
<span class="si-avatar">{c.initials}</span>
|
||||
<span>
|
||||
<span class="si-client__name">{c.name}</span>
|
||||
<span class="si-client__mail">{c.email}</span>
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>{c.age} anni</td>
|
||||
<td>{c.tags[0] && <span class="si-tag">{c.tags[0]}</span>}</td>
|
||||
<td>{c.lastMeasurement}</td>
|
||||
<td>
|
||||
<div class="si-meter">
|
||||
<span class="si-bar">
|
||||
<span class:list={['si-bar__fill', `si-bar__fill--${livello(c.stress)}`]} style={`width:${c.stress}%`}></span>
|
||||
</span>
|
||||
<span class="si-table__num">{c.stress}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{c.alerts > 0
|
||||
? <span class:list={['si-badge', c.alerts >= 2 ? 'si-badge--bad' : 'si-badge--warn']}>{c.alerts}</span>
|
||||
: <span class="si-muted">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="si-muted si-small si-empty" id="si-vuoto" hidden>Nessun cliente corrisponde alla ricerca.</p>
|
||||
</Card>
|
||||
</StressIndex>
|
||||
|
||||
<script>
|
||||
// Filtro dell'elenco: nasconde le righe che non contengono il testo cercato e tiene
|
||||
// aggiornato il conteggio dei risultati.
|
||||
const input = document.getElementById('si-cerca') as HTMLInputElement | null;
|
||||
const table = document.getElementById('si-tabella-clienti');
|
||||
const counter = document.getElementById('si-conteggio');
|
||||
const empty = document.getElementById('si-vuoto');
|
||||
if (input && table && counter && empty) {
|
||||
const rows = Array.from(table.querySelectorAll<HTMLTableRowElement>('tbody tr'));
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value.trim().toLowerCase();
|
||||
let visible = 0;
|
||||
for (const row of rows) {
|
||||
const match = !q || (row.dataset.cerca ?? '').includes(q);
|
||||
row.hidden = !match;
|
||||
if (match) visible += 1;
|
||||
}
|
||||
counter.textContent = `${visible} ${visible === 1 ? 'risultato' : 'risultati'}`;
|
||||
empty.hidden = visible > 0;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
// Impostazioni: profilo, notifiche, account. I campi sono ancora un modulo dimostrativo —
|
||||
// non c'è un endpoint di salvataggio — ma la struttura è quella definitiva.
|
||||
import StressIndex from '../../../layouts/StressIndex.astro';
|
||||
import Card from '../../../components/stress-index/Card.astro';
|
||||
import { professional } from '../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
|
||||
const [nome = '', cognome = ''] = professional.name.replace(/^Dr\.?\s*/i, '').split(' ');
|
||||
const GIORNI = ['Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato', 'Domenica'];
|
||||
const ORE = ['07:00', '08:00', '09:00', '18:00', '19:00', '20:00'];
|
||||
const TABS = [
|
||||
{ key: 'profilo', label: 'Profilo' },
|
||||
{ key: 'notifiche', label: 'Notifiche' },
|
||||
{ key: 'account', label: 'Account' },
|
||||
];
|
||||
---
|
||||
<StressIndex title="Impostazioni" crumbs={['Impostazioni']}>
|
||||
<div class="si-head">
|
||||
<h1>Impostazioni</h1>
|
||||
<p>Profilo, notifiche e account</p>
|
||||
</div>
|
||||
|
||||
<div class="si-tabs" role="tablist" id="si-set-tabs">
|
||||
{TABS.map((t, i) => (
|
||||
<button type="button" role="tab" class="si-tabs__btn" data-tab={t.key} aria-selected={i === 0 ? 'true' : 'false'}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section data-panel="profilo" class="si-stack">
|
||||
<Card title="Dati professionista">
|
||||
<div class="si-form-grid">
|
||||
<label class="si-field">Titolo<input type="text" value="Dr." /></label>
|
||||
<label class="si-field">Professione<input type="text" value={professional.role} /></label>
|
||||
<label class="si-field">Nome<input type="text" value={nome} /></label>
|
||||
<label class="si-field">Cognome<input type="text" value={cognome} /></label>
|
||||
<label class="si-field">Specializzazione<input type="text" value={professional.role} /></label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Studio">
|
||||
<div class="si-form-grid">
|
||||
<label class="si-field">Nome studio<input type="text" value={professional.studio} /></label>
|
||||
<label class="si-field">Indirizzo<input type="text" placeholder="Via Roma 1, Bologna" /></label>
|
||||
<label class="si-field">Telefono<input type="tel" placeholder="+39 051 123456" /></label>
|
||||
<label class="si-field">Sito web<input type="url" placeholder="https://studio.it" /></label>
|
||||
</div>
|
||||
<p class="si-mt"><button type="button" class="si-btn si-btn--solid">Salva</button></p>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section data-panel="notifiche" hidden>
|
||||
<Card title="Preferenze di notifica">
|
||||
<ul class="si-list si-list--rows si-prefs">
|
||||
<li>
|
||||
<span>
|
||||
<strong>Riassunto settimanale via email</strong>
|
||||
<span class="si-muted si-small">Un riepilogo periodico dell'andamento dei clienti</span>
|
||||
</span>
|
||||
<input type="checkbox" checked />
|
||||
</li>
|
||||
<li>
|
||||
<span>
|
||||
<strong>Alert immediati</strong>
|
||||
<span class="si-muted si-small">Email non appena viene generato un alert cliente</span>
|
||||
</span>
|
||||
<input type="checkbox" checked />
|
||||
</li>
|
||||
<li>
|
||||
<span>
|
||||
<strong>Novità di prodotto</strong>
|
||||
<span class="si-muted si-small">Aggiornamenti sulle nuove funzionalità</span>
|
||||
</span>
|
||||
<input type="checkbox" />
|
||||
</li>
|
||||
</ul>
|
||||
<div class="si-form-grid si-mt">
|
||||
<label class="si-field">Giorno dell'invio
|
||||
<select>{GIORNI.map((g) => <option>{g}</option>)}</select>
|
||||
</label>
|
||||
<label class="si-field">Ora dell'invio
|
||||
<select>{ORE.map((o) => <option>{o}</option>)}</select>
|
||||
</label>
|
||||
</div>
|
||||
<p class="si-mt"><button type="button" class="si-btn si-btn--solid">Salva preferenze</button></p>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section data-panel="account" hidden class="si-stack">
|
||||
<Card title="Accesso">
|
||||
<label class="si-field">Email<input type="email" value={professional.email} readonly /></label>
|
||||
<p class="si-muted si-small si-mt">Per cambiare l'indirizzo email serve una richiesta al supporto.</p>
|
||||
<div class="si-form-grid si-mt">
|
||||
<label class="si-field">Password attuale<input type="password" autocomplete="current-password" /></label>
|
||||
<label class="si-field">Nuova password<input type="password" autocomplete="new-password" /></label>
|
||||
<label class="si-field">Conferma nuova password<input type="password" autocomplete="new-password" /></label>
|
||||
</div>
|
||||
<p class="si-mt"><button type="button" class="si-btn si-btn--solid">Aggiorna password</button></p>
|
||||
</Card>
|
||||
|
||||
<Card title="Abbonamento">
|
||||
<p class="si-muted si-small">Per il piano e la fatturazione fa riferimento al supporto Stress Index.</p>
|
||||
</Card>
|
||||
|
||||
<p><a class="si-btn" href="/admin/logout">Esci dall'account</a></p>
|
||||
</section>
|
||||
</StressIndex>
|
||||
|
||||
<script>
|
||||
const tabs = document.getElementById('si-set-tabs');
|
||||
if (tabs) {
|
||||
const buttons = Array.from(tabs.querySelectorAll<HTMLButtonElement>('.si-tabs__btn'));
|
||||
const panels = Array.from(document.querySelectorAll<HTMLElement>('[data-panel]'));
|
||||
for (const btn of buttons) {
|
||||
btn.addEventListener('click', () => {
|
||||
for (const b of buttons) b.setAttribute('aria-selected', String(b === btn));
|
||||
for (const p of panels) p.hidden = p.dataset.panel !== btn.dataset.tab;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
// Dashboard "Oggi": ciò che il professionista deve guardare per primo — alert, clienti da
|
||||
// ricontattare, note recenti. L'accesso è già filtrato dal middleware (ruolo piattaforme/admin).
|
||||
import StressIndex from '../../../layouts/StressIndex.astro';
|
||||
import Card from '../../../components/stress-index/Card.astro';
|
||||
import Kpi from '../../../components/stress-index/Kpi.astro';
|
||||
import AlertRow from '../../../components/stress-index/AlertRow.astro';
|
||||
import AndamentoChart from '../../../components/stress-index/charts/AndamentoChart';
|
||||
import {
|
||||
alerts, clients, measurementsByClient, notes, studioKpis, professional, OGGI, daysSince,
|
||||
} from '../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
|
||||
const prioritari = alerts.slice(0, 4);
|
||||
const misurazioniOggi = Object.values(measurementsByClient).flat().filter((m) => m.date === OGGI).length;
|
||||
const daContattare = [...clients]
|
||||
.sort((a, b) => daysSince(b.lastMeasurement) - daysSince(a.lastMeasurement))
|
||||
.slice(0, 5);
|
||||
const ultimeNote = [...notes]
|
||||
.sort((a, b) => (a.when < b.when ? 1 : -1))
|
||||
.slice(0, 4)
|
||||
.map((n) => ({ ...n, clientName: clients.find((c) => c.id === n.clientId)?.name }));
|
||||
---
|
||||
<StressIndex title="Oggi">
|
||||
<div class="si-head">
|
||||
<h1>Buongiorno, {professional.name.replace(/^Dr\.\s*/, '')}</h1>
|
||||
<p>Oggi, {OGGI}</p>
|
||||
</div>
|
||||
|
||||
<div class="si-grid si-grid--kpi">
|
||||
<Kpi label="Clienti attivi" value={studioKpis.totalClients} delta="+2 questo mese" trend="up" />
|
||||
<Kpi
|
||||
label="Misurazioni oggi"
|
||||
value={misurazioniOggi}
|
||||
delta={misurazioniOggi === 0 ? 'Nessuna ancora' : 'In aggiornamento'}
|
||||
trend={misurazioniOggi === 0 ? 'down' : 'up'}
|
||||
/>
|
||||
<Kpi label="Alert attivi" value={studioKpis.activeAlerts} delta="+3 questa settimana" trend="down" />
|
||||
<Kpi label="Stress medio" value={studioKpis.avgStress} delta="-4% vs mese scorso" trend="up" />
|
||||
</div>
|
||||
|
||||
<div class="si-grid si-grid--2 si-mt">
|
||||
<div class="si-stack">
|
||||
<Card title="Andamento medio aggregato">
|
||||
<AndamentoChart client:load measurementsByClient={measurementsByClient} />
|
||||
</Card>
|
||||
|
||||
<Card title="Alert prioritari" actionLabel="Vedi tutti" actionHref="/piattaforme/stress-index/clienti">
|
||||
{prioritari.map((a) => <AlertRow alert={a} />)}
|
||||
</Card>
|
||||
|
||||
<Card title="Da contattare">
|
||||
<ul class="si-list si-list--rows">
|
||||
{daContattare.map((c) => (
|
||||
<li>
|
||||
<span>
|
||||
<strong>{c.name}</strong>
|
||||
<span class="si-muted"> · ultima misurazione {daysSince(c.lastMeasurement)} giorni fa</span>
|
||||
</span>
|
||||
<a class="si-btn" href={`/piattaforme/stress-index/clienti/${c.id}`}>Scheda</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="si-stack">
|
||||
<Card title="Misurazioni di oggi">
|
||||
{misurazioniOggi === 0 ? (
|
||||
<p class="si-muted si-small">Nessuna misurazione registrata oggi. Le rilevazioni dei clienti compaiono qui appena arrivano.</p>
|
||||
) : (
|
||||
<p class="si-kpi__value">{misurazioniOggi}</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Ultime note">
|
||||
<ul class="si-list">
|
||||
{ultimeNote.map((n) => (
|
||||
<li class="si-note">
|
||||
<p>{n.text}</p>
|
||||
<p class="si-note__meta">{n.clientName ?? 'Nota generale'} · {n.when}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</StressIndex>
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
// Organizzazione: raggruppa più professionisti sotto un'unica struttura. Finché non c'è
|
||||
// il backend, il modulo raccoglie solo il nome e il pulsante resta disabilitato a vuoto.
|
||||
import StressIndex from '../../../layouts/StressIndex.astro';
|
||||
export const prerender = false;
|
||||
---
|
||||
<StressIndex title="Organizzazione" crumbs={['Organizzazione']}>
|
||||
<div class="si-center">
|
||||
<div class="si-card si-org">
|
||||
<h1>Crea la tua organizzazione</h1>
|
||||
<p class="si-muted si-small">
|
||||
Raggruppa più professionisti sotto un'unica organizzazione per condividere clienti,
|
||||
dati e impostazioni dello studio.
|
||||
</p>
|
||||
<label class="si-field si-mt">
|
||||
Nome organizzazione
|
||||
<input type="text" id="si-org-nome" placeholder="Studio Bianchi Associati" />
|
||||
</label>
|
||||
<p class="si-mt">
|
||||
<button type="button" class="si-btn si-btn--solid si-org__cta" id="si-org-crea" disabled>Crea organizzazione</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</StressIndex>
|
||||
|
||||
<script>
|
||||
// Il pulsante si sblocca solo con un nome valorizzato.
|
||||
const input = document.getElementById('si-org-nome') as HTMLInputElement | null;
|
||||
const btn = document.getElementById('si-org-crea') as HTMLButtonElement | null;
|
||||
if (input && btn) {
|
||||
input.addEventListener('input', () => { btn.disabled = input.value.trim().length === 0; });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
// Modulo Sport: carico di lavoro e sessioni degli atleti seguiti. Le tre viste sono già
|
||||
// tutte renderizzate: il cambio scheda è solo un mostra/nascondi, non serve un'isola.
|
||||
import StressIndex from '../../../../layouts/StressIndex.astro';
|
||||
import Card from '../../../../components/stress-index/Card.astro';
|
||||
import Kpi from '../../../../components/stress-index/Kpi.astro';
|
||||
import DataChart from '../../../../components/stress-index/charts/DataChart';
|
||||
import { sessions, athletes, sessionsPerWeek } from '../../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
|
||||
const trimpMedio = Math.round(sessions.reduce((s, x) => s + x.trimp, 0) / sessions.length);
|
||||
const sessioniSettimana = sessionsPerWeek[sessionsPerWeek.length - 1]?.count ?? 0;
|
||||
const nomeAtleta = (id: string) => athletes.find((a) => a.clientId === id)?.name ?? id;
|
||||
const TABS = [
|
||||
{ key: 'dashboard', label: 'Dashboard' },
|
||||
{ key: 'sessioni', label: 'Sessioni' },
|
||||
{ key: 'atleti', label: 'Atleti' },
|
||||
];
|
||||
---
|
||||
<StressIndex title="Sport" crumbs={['Sport']}>
|
||||
<div class="si-head">
|
||||
<h1>Sport</h1>
|
||||
<p>Sessioni, carico e monitoraggio degli atleti</p>
|
||||
</div>
|
||||
|
||||
<div class="si-tabs" role="tablist" id="si-sport-tabs">
|
||||
{TABS.map((t, i) => (
|
||||
<button type="button" role="tab" class="si-tabs__btn" data-tab={t.key} aria-selected={i === 0 ? 'true' : 'false'}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section data-panel="dashboard" class="si-stack">
|
||||
<div class="si-grid si-grid--kpi">
|
||||
<Kpi label="Sessioni totali" value={sessions.length} />
|
||||
<Kpi label="Sessioni questa settimana" value={sessioniSettimana} />
|
||||
<Kpi label="TRIMP medio" value={trimpMedio} />
|
||||
<Kpi label="Atleti attivi" value={athletes.length} />
|
||||
</div>
|
||||
|
||||
<Card title="Sessioni per settimana">
|
||||
<DataChart client:visible type="bar" data={sessionsPerWeek} xKey="week" series={[{ key: 'count', label: 'Sessioni' }]} />
|
||||
</Card>
|
||||
|
||||
<Card title="Ultime sessioni">
|
||||
<div class="si-scroll-x">
|
||||
<table class="si-table">
|
||||
<thead><tr><th>Atleta</th><th>Data</th><th>Sport</th><th>Durata</th><th>TRIMP</th><th>HR medio</th><th>DFA A1</th></tr></thead>
|
||||
<tbody>
|
||||
{sessions.map((s) => (
|
||||
<tr>
|
||||
<td><strong>{nomeAtleta(s.athleteId)}</strong></td>
|
||||
<td class="si-table__num">{s.date}</td>
|
||||
<td>{s.type}</td>
|
||||
<td class="si-table__num">{s.durationMin} min</td>
|
||||
<td class="si-table__num">{s.trimp}</td>
|
||||
<td class="si-table__num">{s.hrAvg}</td>
|
||||
<td class="si-table__num">{s.dfaA1}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section data-panel="sessioni" hidden>
|
||||
<Card title="Tutte le sessioni">
|
||||
<div class="si-scroll-x">
|
||||
<table class="si-table">
|
||||
<thead><tr><th>Atleta</th><th>Data</th><th>Sport</th><th>Durata</th><th>Carico</th><th>TRIMP</th><th>HR medio</th><th>DFA A1</th></tr></thead>
|
||||
<tbody>
|
||||
{sessions.map((s) => (
|
||||
<tr>
|
||||
<td><strong>{nomeAtleta(s.athleteId)}</strong></td>
|
||||
<td class="si-table__num">{s.date}</td>
|
||||
<td>{s.type}</td>
|
||||
<td class="si-table__num">{s.durationMin} min</td>
|
||||
<td class="si-table__num">{s.load}</td>
|
||||
<td class="si-table__num">{s.trimp}</td>
|
||||
<td class="si-table__num">{s.hrAvg}</td>
|
||||
<td class="si-table__num">{s.dfaA1}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section data-panel="atleti" hidden>
|
||||
<Card title="Atleti monitorati">
|
||||
<table class="si-table">
|
||||
<thead><tr><th>Atleta</th><th>Sport</th><th>Carico settimanale</th></tr></thead>
|
||||
<tbody>
|
||||
{athletes.map((a) => (
|
||||
<tr>
|
||||
<td><a href={`/piattaforme/stress-index/clienti/${a.clientId}`}>{a.name}</a></td>
|
||||
<td>{a.sport}</td>
|
||||
<td class="si-table__num">{a.weeklyLoad}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</section>
|
||||
</StressIndex>
|
||||
|
||||
<script>
|
||||
// Schede: un solo pannello visibile alla volta, selezione riflessa su aria-selected.
|
||||
const tabs = document.getElementById('si-sport-tabs');
|
||||
if (tabs) {
|
||||
const buttons = Array.from(tabs.querySelectorAll<HTMLButtonElement>('.si-tabs__btn'));
|
||||
const panels = Array.from(document.querySelectorAll<HTMLElement>('[data-panel]'));
|
||||
for (const btn of buttons) {
|
||||
btn.addEventListener('click', () => {
|
||||
for (const b of buttons) b.setAttribute('aria-selected', String(b === btn));
|
||||
for (const p of panels) p.hidden = p.dataset.panel !== btn.dataset.tab;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
// Monitoraggio live: finché non arriva la sorgente dati in tempo reale la vista resta
|
||||
// vuota per costruzione, e spiega cosa succederà quando un atleta avvia una sessione.
|
||||
import StressIndex from '../../../../layouts/StressIndex.astro';
|
||||
export const prerender = false;
|
||||
|
||||
const PASSI = [
|
||||
"L'atleta avvia una sessione dall'app Stress Index collegata alla fascia cardio.",
|
||||
'I dati di frequenza cardiaca e DFA A1 arrivano in tempo reale a questa vista.',
|
||||
"La scheda dell'atleta compare qui da sola, con le metriche aggiornate in continuo.",
|
||||
'A fine sessione la scheda si chiude e il riepilogo confluisce nello storico Sport.',
|
||||
];
|
||||
---
|
||||
<StressIndex title="Team live" crumbs={['Sport', 'Team live']}>
|
||||
<div class="si-head si-head--row">
|
||||
<div>
|
||||
<h1>Team live</h1>
|
||||
<p>Nessun atleta in sessione</p>
|
||||
</div>
|
||||
<label class="si-field si-field--inline">
|
||||
<span class="si-visually-hidden">Ordina per</span>
|
||||
<select>
|
||||
<option value="nome">Ordina per nome</option>
|
||||
<option value="hr">Ordina per HR corrente</option>
|
||||
<option value="carico">Ordina per carico sessione</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="si-card si-live">
|
||||
<p class="si-live__title">Nessun atleta in sessione</p>
|
||||
<p class="si-muted si-small">Quando un atleta avvia una sessione dall'app, la sua scheda compare qui in tempo reale.</p>
|
||||
|
||||
<div class="si-live__how">
|
||||
<p class="si-card__title">Come funziona</p>
|
||||
<ol class="si-steps">
|
||||
{PASSI.map((p, i) => (
|
||||
<li><span class="si-steps__num">{i + 1}</span><span>{p}</span></li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</StressIndex>
|
||||
@@ -0,0 +1,287 @@
|
||||
/* Stile della piattaforma Stress Index.
|
||||
Vive in un foglio globale, non in <style> scoped, perché le stesse classi servono sia
|
||||
ai componenti Astro sia alle isole React (grafici e viste interattive), che lo scope
|
||||
di Astro non raggiungerebbe. Tutto è prefissato `.si` e usa i token del sito. */
|
||||
|
||||
.si {
|
||||
--si-line: #e4dfd7;
|
||||
--si-surface: #ffffff;
|
||||
--si-ok: #6f8f6a;
|
||||
--si-warn: #c08a3e;
|
||||
--si-bad: #b05a4e;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
background: var(--c-bg-alt);
|
||||
}
|
||||
|
||||
/* --- sidebar --- */
|
||||
.si-side {
|
||||
width: 250px; flex: 0 0 250px; height: 100vh; position: sticky; top: 0; overflow-y: auto;
|
||||
background: var(--c-dark); color: #cfc6bd; padding: 28px 20px 40px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.si-brand {
|
||||
display: block; font-family: var(--font-heading); font-size: 1.35rem; font-weight: 600;
|
||||
letter-spacing: .1em; text-transform: uppercase; color: #fff; text-decoration: none; line-height: 1.1;
|
||||
}
|
||||
.si-brand .eyebrow { display: block; font-size: .7rem; letter-spacing: .18em; color: var(--c-accent); margin-bottom: 5px; }
|
||||
.si-home {
|
||||
display: inline-block; margin: 16px 0 22px; font-family: var(--font-heading); font-size: .68rem;
|
||||
font-weight: 600; letter-spacing: .16em; text-transform: uppercase; color: var(--c-accent);
|
||||
text-decoration: none; border-bottom: 1px solid transparent;
|
||||
}
|
||||
.si-home:hover { border-bottom-color: var(--c-accent); }
|
||||
|
||||
.si-nav { display: flex; flex-direction: column; gap: 2px; border-top: 1px solid rgba(255,255,255,.12); padding-top: 16px; }
|
||||
.si-nav__link {
|
||||
display: flex; align-items: center; gap: 8px; padding: 9px 12px; text-decoration: none;
|
||||
font-family: var(--font-heading); font-size: .74rem; font-weight: 600; letter-spacing: .12em;
|
||||
text-transform: uppercase; color: #b6ada3; border-left: 3px solid transparent;
|
||||
}
|
||||
.si-nav__link:hover { color: #fff; background: rgba(255,255,255,.06); }
|
||||
.si-nav__link.is-active { color: #fff; background: rgba(184,169,140,.22); border-left-color: var(--c-accent); }
|
||||
.si-nav__badge {
|
||||
margin-left: auto; background: var(--c-accent); color: var(--c-dark); border-radius: 999px;
|
||||
padding: 1px 7px; font-size: .58rem; letter-spacing: .1em;
|
||||
}
|
||||
|
||||
.si-foot {
|
||||
display: flex; flex-direction: column; gap: 9px; margin-top: auto; padding-top: 20px;
|
||||
border-top: 1px solid rgba(255,255,255,.12);
|
||||
}
|
||||
.si-foot__role { font-size: .74rem; letter-spacing: .1em; text-transform: uppercase; color: var(--c-accent); }
|
||||
.si-foot a { font-size: .74rem; letter-spacing: .1em; text-transform: uppercase; color: #8d8379; text-decoration: none; }
|
||||
.si-foot a:hover { color: #fff; }
|
||||
|
||||
/* --- shell contenuto --- */
|
||||
.si-body { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 100vh; }
|
||||
.si-top {
|
||||
display: flex; align-items: baseline; justify-content: space-between; gap: 20px;
|
||||
background: var(--si-surface); border-bottom: 1px solid var(--si-line); padding: 16px 32px;
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.si-top p { margin: 0; }
|
||||
.si-top__crumbs { font-family: var(--font-heading); font-size: .74rem; letter-spacing: .14em; text-transform: uppercase; color: var(--c-text-light); }
|
||||
.si-top__crumbs strong { color: var(--c-heading); font-weight: 600; }
|
||||
.si-top__studio { font-size: .8rem; color: var(--c-text-light); }
|
||||
.si-main { padding: 34px 32px 70px; }
|
||||
|
||||
/* --- intestazione di pagina --- */
|
||||
.si-head { margin-bottom: 28px; }
|
||||
.si-head h1 { font-size: clamp(1.6rem, 3vw, 2.1rem); text-transform: none; letter-spacing: .02em; margin-bottom: .25em; }
|
||||
.si-head p { margin: 0; color: var(--c-text-light); font-size: .9rem; }
|
||||
|
||||
/* --- griglie --- */
|
||||
.si-grid { display: grid; gap: 20px; }
|
||||
.si-grid--kpi { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
|
||||
.si-grid--2 { grid-template-columns: 2fr 1fr; align-items: start; }
|
||||
.si-grid--halves { grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
|
||||
.si-stack { display: flex; flex-direction: column; gap: 20px; }
|
||||
.si-mt { margin-top: 20px; }
|
||||
|
||||
/* --- card --- */
|
||||
.si-card { background: var(--si-surface); border: 1px solid var(--si-line); padding: 20px 22px; }
|
||||
.si-card__head { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 16px; }
|
||||
.si-card__title {
|
||||
margin: 0; font-family: var(--font-heading); font-size: .78rem; font-weight: 600;
|
||||
letter-spacing: .16em; text-transform: uppercase; color: var(--c-heading);
|
||||
}
|
||||
.si-card__action { font-family: var(--font-heading); font-size: .68rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--c-accent-dark); text-decoration: none; }
|
||||
.si-card__action:hover { text-decoration: underline; }
|
||||
|
||||
/* --- kpi --- */
|
||||
.si-kpi__label { font-size: .78rem; color: var(--c-text-light); letter-spacing: .04em; }
|
||||
.si-kpi__value { font-family: var(--font-heading); font-size: 2rem; font-weight: 500; color: var(--c-heading); margin: 6px 0 4px; }
|
||||
.si-kpi__delta { font-size: .78rem; font-weight: 600; }
|
||||
.si-kpi__delta--up { color: var(--si-ok); }
|
||||
.si-kpi__delta--down { color: var(--si-bad); }
|
||||
|
||||
/* --- liste e tabelle --- */
|
||||
.si-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.si-list--rows > li { display: flex; align-items: center; justify-content: space-between; gap: 14px; font-size: .88rem; }
|
||||
.si-list--defs .si-list__row { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; font-size: .88rem; }
|
||||
.si-list--defs dd { margin: 0; color: var(--c-heading); }
|
||||
.si-muted { color: var(--c-text-light); }
|
||||
.si-small { font-size: .78rem; }
|
||||
.si-note p { margin: 0; font-size: .84rem; }
|
||||
.si-note__meta { margin-top: 2px; font-size: .76rem; color: var(--c-text-light); }
|
||||
|
||||
.si-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
.si-table th, .si-table td { border-bottom: 1px solid var(--si-line); padding: 11px 10px; text-align: left; vertical-align: middle; }
|
||||
.si-table th {
|
||||
font-family: var(--font-heading); font-size: .68rem; font-weight: 600; letter-spacing: .14em;
|
||||
text-transform: uppercase; color: var(--c-text-light);
|
||||
}
|
||||
.si-table tbody tr:hover { background: var(--c-bg-alt); }
|
||||
.si-table a { color: var(--c-accent-dark); font-weight: 600; text-decoration: none; }
|
||||
.si-table a:hover { text-decoration: underline; }
|
||||
.si-table__num { font-family: var(--font-heading); }
|
||||
|
||||
/* --- elementi --- */
|
||||
.si-avatar {
|
||||
display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px;
|
||||
border-radius: 50%; background: var(--c-accent); color: #fff; font-family: var(--font-heading);
|
||||
font-size: .74rem; font-weight: 600; letter-spacing: .04em; flex: 0 0 34px;
|
||||
}
|
||||
.si-tag {
|
||||
display: inline-block; border: 1px solid var(--si-line); padding: 2px 9px; margin-right: 5px;
|
||||
font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--c-text-light);
|
||||
}
|
||||
.si-badge { display: inline-block; padding: 2px 9px; font-size: .68rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.si-badge--ok { background: rgba(111,143,106,.16); color: var(--si-ok); }
|
||||
.si-badge--warn { background: rgba(192,138,62,.16); color: var(--si-warn); }
|
||||
.si-badge--bad { background: rgba(176,90,78,.16); color: var(--si-bad); }
|
||||
|
||||
.si-alert { display: flex; gap: 12px; align-items: flex-start; padding: 12px 0; border-bottom: 1px solid var(--si-line); }
|
||||
.si-alert:last-child { border-bottom: 0; }
|
||||
.si-alert__mark { width: 7px; height: 7px; border-radius: 50%; margin-top: 7px; flex: 0 0 7px; background: var(--si-warn); }
|
||||
.si-alert--error .si-alert__mark { background: var(--si-bad); }
|
||||
.si-alert__text { font-size: .88rem; font-weight: 600; color: var(--c-heading); }
|
||||
.si-alert__meta { font-size: .76rem; color: var(--c-text-light); }
|
||||
|
||||
.si-btn {
|
||||
display: inline-block; border: 1px solid var(--si-line); background: none; cursor: pointer;
|
||||
padding: 6px 14px; font-family: var(--font-heading); font-size: .66rem; font-weight: 600;
|
||||
letter-spacing: .14em; text-transform: uppercase; color: var(--c-heading); text-decoration: none;
|
||||
}
|
||||
.si-btn:hover { border-color: var(--c-accent); color: var(--c-accent-dark); }
|
||||
.si-btn--solid { background: var(--c-dark); border-color: var(--c-dark); color: #fff; }
|
||||
.si-btn--solid:hover { background: var(--c-dark-2); color: #fff; }
|
||||
|
||||
.si-field { display: flex; flex-direction: column; gap: 6px; font-size: .82rem; color: var(--c-text-light); }
|
||||
.si-field input, .si-field select, .si-field textarea {
|
||||
border: 1px solid var(--si-line); background: #fff; padding: 9px 11px; font: inherit;
|
||||
font-size: .88rem; color: var(--c-text); border-radius: 0;
|
||||
}
|
||||
.si-field input:focus, .si-field select:focus, .si-field textarea:focus { outline: 2px solid var(--c-accent); outline-offset: -2px; }
|
||||
|
||||
/* --- misura, barre --- */
|
||||
.si-bar { height: 6px; background: var(--c-bg-alt); overflow: hidden; }
|
||||
.si-bar__fill { height: 100%; background: var(--c-accent); }
|
||||
.si-bar__fill--ok { background: var(--si-ok); }
|
||||
.si-bar__fill--warn { background: var(--si-warn); }
|
||||
.si-bar__fill--bad { background: var(--si-bad); }
|
||||
|
||||
.si-score { display: flex; align-items: baseline; gap: 8px; }
|
||||
.si-score__value { font-family: var(--font-heading); font-size: 1.9rem; font-weight: 500; color: var(--c-heading); }
|
||||
.si-score__max { font-size: .82rem; color: var(--c-text-light); }
|
||||
|
||||
/* --- tab (isole React) --- */
|
||||
.si-tabs { display: flex; gap: 26px; border-bottom: 1px solid var(--si-line); margin-bottom: 22px; }
|
||||
.si-tabs__btn {
|
||||
background: none; border: 0; border-bottom: 2px solid transparent; cursor: pointer; padding: 10px 2px;
|
||||
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .14em;
|
||||
text-transform: uppercase; color: var(--c-text-light);
|
||||
}
|
||||
.si-tabs__btn[aria-selected='true'] { color: var(--c-heading); border-bottom-color: var(--c-accent); }
|
||||
|
||||
.si-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
|
||||
.si-chip {
|
||||
background: none; border: 1px solid var(--si-line); cursor: pointer; padding: 5px 12px;
|
||||
font-family: var(--font-heading); font-size: .66rem; font-weight: 600; letter-spacing: .12em;
|
||||
text-transform: uppercase; color: var(--c-text-light);
|
||||
}
|
||||
.si-chip[aria-pressed='true'] { border-color: var(--c-accent); background: var(--c-bg-alt); color: var(--c-accent-dark); }
|
||||
|
||||
.si-chart { width: 100%; }
|
||||
.si-chart__empty { font-size: .85rem; color: var(--c-text-light); }
|
||||
|
||||
/* --- mobile --- */
|
||||
.si-menu-btn { display: none; }
|
||||
@media (max-width: 900px) {
|
||||
.si { display: block; }
|
||||
.si-menu-btn {
|
||||
display: block; position: sticky; top: 0; z-index: 20; background: var(--c-dark); color: #fff;
|
||||
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em;
|
||||
text-transform: uppercase; padding: 14px 20px; cursor: pointer;
|
||||
}
|
||||
.si-side { display: none; width: auto; flex: none; height: auto; position: static; }
|
||||
.si-menu-toggle:checked ~ .si-side { display: flex; }
|
||||
.si-top { padding: 14px 20px; flex-direction: column; gap: 4px; }
|
||||
.si-main { padding: 24px 20px 60px; }
|
||||
.si-grid--2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* --- elenco clienti --- */
|
||||
.si-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||
.si-toolbar p { margin: 0; }
|
||||
.si-field--inline { flex-direction: row; align-items: center; }
|
||||
.si-field--inline input { min-width: 260px; }
|
||||
.si-visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.si-client { display: flex; align-items: center; gap: 10px; text-decoration: none; }
|
||||
.si-client__name { display: block; font-weight: 600; color: var(--c-heading); }
|
||||
.si-client__mail { display: block; font-size: .76rem; color: var(--c-text-light); font-weight: 400; }
|
||||
.si-client:hover .si-client__name { color: var(--c-accent-dark); }
|
||||
.si-meter { display: flex; align-items: center; gap: 10px; }
|
||||
.si-meter .si-bar { width: 74px; flex: 0 0 74px; }
|
||||
.si-empty { margin: 16px 0 0; }
|
||||
|
||||
/* --- scheda cliente --- */
|
||||
.si-client-head { display: flex; align-items: center; gap: 16px; }
|
||||
.si-client-head h1 { margin: 0 0 2px; font-size: clamp(1.5rem, 2.6vw, 1.9rem); text-transform: none; letter-spacing: .02em; }
|
||||
.si-client-head p { margin: 0; }
|
||||
.si-avatar--lg { width: 52px; height: 52px; flex: 0 0 52px; font-size: .95rem; }
|
||||
|
||||
.si-gauges { display: flex; flex-wrap: wrap; gap: 20px; justify-content: space-around; }
|
||||
.si-gauge { width: 150px; text-align: center; }
|
||||
.si-gauge__svg { width: 100%; height: auto; }
|
||||
.si-gauge__value { margin: 2px 0 0; font-family: var(--font-heading); font-size: 1.6rem; font-weight: 500; color: var(--c-heading); }
|
||||
.si-gauge__max { font-size: .78rem; color: var(--c-text-light); }
|
||||
.si-gauge__label { margin: 0; font-size: .7rem; letter-spacing: .1em; text-transform: uppercase; color: var(--c-text-light); }
|
||||
|
||||
.si-insight { background: var(--c-dark); color: #cfc6bd; padding: 22px 24px; }
|
||||
.si-insight p { margin: 0; font-size: .9rem; }
|
||||
.si-insight__title {
|
||||
font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .18em;
|
||||
text-transform: uppercase; color: var(--c-accent); margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
.si-scroll-x { overflow-x: auto; }
|
||||
.si-note-card { border: 1px solid var(--si-line); padding: 12px 14px; }
|
||||
.si-note-card p { margin: 0; font-size: .86rem; }
|
||||
|
||||
.si-chat { list-style: none; margin: 0 0 18px; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.si-chat__row { display: flex; }
|
||||
.si-chat__row--professionista { justify-content: flex-end; }
|
||||
.si-chat__bubble { max-width: 70%; padding: 10px 14px; background: var(--c-bg-alt); border: 1px solid var(--si-line); }
|
||||
.si-chat__row--professionista .si-chat__bubble { background: var(--c-dark); border-color: var(--c-dark); color: #efe9e1; }
|
||||
.si-chat__bubble p { margin: 0; font-size: .86rem; }
|
||||
.si-chat__when { margin-top: 4px !important; font-size: .7rem; opacity: .7; }
|
||||
.si-chat__compose { display: flex; align-items: center; gap: 10px; }
|
||||
.si-chat__compose .si-field { flex: 1; }
|
||||
.si-chat__compose input { width: 100%; }
|
||||
|
||||
.si-tag-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
|
||||
.si-tag--removable { display: inline-flex; align-items: center; gap: 7px; margin-right: 0; }
|
||||
.si-tag--removable button { background: none; border: 0; cursor: pointer; color: inherit; font-size: .7rem; padding: 0; }
|
||||
|
||||
/* --- analytics, sport, impostazioni --- */
|
||||
.si-head--row { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
|
||||
.si-segment { border: 1px solid var(--si-line); padding: 16px 18px; }
|
||||
.si-segment__label { margin: 0; font-weight: 600; color: var(--c-heading); font-size: .9rem; }
|
||||
.si-segment p { margin: 0; }
|
||||
.si-segment .si-kpi__value { margin: 6px 0 2px; }
|
||||
.si-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px; }
|
||||
.si-prefs > li { align-items: flex-start; }
|
||||
.si-prefs strong { display: block; font-size: .88rem; color: var(--c-heading); }
|
||||
.si-prefs .si-small { display: block; }
|
||||
|
||||
/* --- team live --- */
|
||||
.si-live { text-align: center; padding: 54px 26px; }
|
||||
.si-live__title { font-family: var(--font-heading); font-size: 1.05rem; color: var(--c-heading); margin: 0 0 6px; }
|
||||
.si-live__how { max-width: 560px; margin: 32px auto 0; border: 1px solid var(--si-line); padding: 20px 22px; text-align: left; background: var(--c-bg-alt); }
|
||||
.si-steps { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.si-steps li { display: flex; gap: 12px; align-items: flex-start; font-size: .86rem; color: var(--c-text); }
|
||||
.si-steps__num {
|
||||
flex: 0 0 24px; width: 24px; height: 24px; border-radius: 50%; background: var(--c-accent); color: #fff;
|
||||
display: inline-flex; align-items: center; justify-content: center; font-family: var(--font-heading);
|
||||
font-size: .72rem; font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- organizzazione --- */
|
||||
.si-center { display: flex; justify-content: center; padding: 40px 0; }
|
||||
.si-org { width: 100%; max-width: 460px; text-align: center; padding: 34px 32px; }
|
||||
.si-org h1 { font-size: 1.4rem; text-transform: none; letter-spacing: .02em; margin-bottom: .4em; }
|
||||
.si-org p { margin: 0; }
|
||||
.si-org .si-field { text-align: left; }
|
||||
.si-org__cta:disabled { opacity: .45; cursor: not-allowed; }
|
||||
Reference in New Issue
Block a user