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:
@@ -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>
|
||||
Reference in New Issue
Block a user