Files
yogibook_aury_new/public/sommario.php
T
2026-10-16 15:57:25 +02:00

1156 lines
45 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php require_once('include/headscript.php'); ?>
<?php
/**
* YogiBook - Sommario per la direzione
*
* Un colpo d'occhio sullo stato della scuola: ogni indicatore ha un semaforo
* (verde / giallo / rosso), il confronto con il periodo precedente e un consiglio.
* Le soglie si cambiano nell'array $SOGLIE qui sotto.
*
* Il periodo finisce sempre OGGI e viene confrontato con lo stesso numero di giorni
* immediatamente precedente (es. ultimi 30 giorni vs i 30 prima).
*/
$pdo = DBHandlerSelect::getInstance()->getConnection();
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
/* =========================================================== SOGLIE (modificabili) */
// [verde se ..., giallo se ..., altrimenti rosso]
$SOGLIE = [
'riempimento' => ['verde' => 70, 'giallo' => 45], // % posti occupati: più alto è meglio
'perse' => ['verde' => 5, 'giallo' => 10], // % lezioni perse: più basso è meglio
'cancellazioni' => ['verde' => 8, 'giallo' => 15], // % cancellazioni sulle prenotazioni
'last_minute' => ['verde' => 20, 'giallo' => 40], // % cancellazioni con meno di 24 ore di preavviso
'riacquisto' => ['verde' => 60, 'giallo' => 40], // % clienti che riacquistano dopo la scadenza
'non_usate' => ['verde' => 5, 'giallo' => 15], // % lezioni rimaste inutilizzate nei pacchetti scaduti
'certificati' => ['verde' => 95, 'giallo' => 80], // % abbonati attivi con certificato valido
'classi_deboli' => ['verde' => 0, 'giallo' => 2], // n. classi sotto il 40% di riempimento
'trend' => ['verde' => -5, 'giallo' => -20], // variazione % rispetto al periodo prima
'soglia_debole' => 40, // sotto questo riempimento una classe è "debole"
'giorni_rinnovo' => 30, // giorni concessi dopo la scadenza per rinnovare
];
if (!function_exists('e')) {
function e($value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
}
function q(PDO $pdo, string $sql, array $p = []): array
{
$st = $pdo->prepare($sql);
$st->execute($p);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
function q1(PDO $pdo, string $sql, array $p = [])
{
$r = q($pdo, $sql, $p);
return $r ? reset($r[0]) : null;
}
function pctv($num, $den): ?float
{
return $den > 0 ? round($num / $den * 100, 1) : null;
}
/** Giorno della settimana in italiano (in service.day è salvato in inglese). */
function giornoIt(string $s): string
{
return strtr($s, [
'Monday' => 'Lunedì',
'Tuesday' => 'Martedì',
'Wednesday' => 'Mercoledì',
'Thursday' => 'Giovedì',
'Friday' => 'Venerdì',
'Saturday' => 'Sabato',
'Sunday' => 'Domenica'
]);
}
function n0($v): string
{
return number_format((float) $v, 0, ',', '.');
}
/** Semaforo: 'verde' | 'giallo' | 'rosso' | 'grigio' (dati insufficienti). */
function semaforo(?float $v, array $s, bool $piuAltoMeglio): string
{
if ($v === null) return 'grigio';
if ($piuAltoMeglio) {
return $v >= $s['verde'] ? 'verde' : ($v >= $s['giallo'] ? 'giallo' : 'rosso');
}
return $v <= $s['verde'] ? 'verde' : ($v <= $s['giallo'] ? 'giallo' : 'rosso');
}
/** Variazione % tra periodo attuale e precedente (null se non confrontabile). */
function delta($cur, $prev): ?float
{
if ($prev === null || $cur === null) return null;
if ((float) $prev == 0.0) return null;
return round(((float) $cur - (float) $prev) / (float) $prev * 100, 1);
}
/* =========================================================== PERIODO */
$periodi = [
'30' => 'Ultimi 30 giorni',
'90' => 'Ultimi 3 mesi',
'180' => 'Ultimi 6 mesi',
'365' => 'Ultimi 12 mesi',
];
$giorni = isset($periodi[$_GET['p'] ?? '']) ? (int) $_GET['p'] : 90;
$oggi = new DateTime('today');
$fine = (clone $oggi)->modify('+1 day'); // escluso
$inizio = (clone $fine)->modify("-{$giorni} days");
$inizioP = (clone $inizio)->modify("-{$giorni} days"); // periodo precedente
$now = date('Y-m-d H:i:s');
/* =========================================================== CALCOLO METRICHE */
/**
* Tutte le metriche di un periodo [$d1, $d2).
* Le lezioni considerano solo quelle già svolte (fino ad adesso).
*/
function metriche(PDO $pdo, string $d1, string $d2, string $now, int $sogliaDebole, int $grazia = 30): array
{
$m = [];
$fineLezioni = min($d2, $now);
// --- Vendite
$o = q($pdo, "SELECT COUNT(*) AS ordini, COALESCE(SUM(nticket), 0) AS lezioni, COUNT(DISTINCT iduser) AS clienti
FROM orderbook WHERE order_date_created >= ? AND order_date_created < ?", [$d1, $d2])[0];
$m['ordini'] = (int) $o['ordini'];
$m['vendute'] = (int) $o['lezioni'];
$m['clienti'] = (int) $o['clienti'];
$m['nuovi'] = (int) q1($pdo, "SELECT COUNT(*) FROM (
SELECT iduser, MIN(order_date_created) AS primo FROM orderbook WHERE iduser IS NOT NULL GROUP BY iduser
) t WHERE t.primo >= ? AND t.primo < ?", [$d1, $d2]);
// --- Lezioni svolte: presenze, perse, persone
$l = q($pdo, "SELECT
COALESCE(SUM(bc.status = 'booked' AND bc.lostlesson = 'N'), 0) AS presenti,
COALESCE(SUM(bc.lostlesson = 'Y' AND bc.status <> 'cancelled'), 0) AS perse,
COUNT(DISTINCT CASE WHEN bc.status = 'booked' AND bc.lostlesson = 'N' THEN bc.iduser END) AS persone
FROM bookingclass bc
JOIN serviceschedule ss ON ss.idserviceschedule = bc.idserviceschedule
WHERE ss.dateschedule >= ? AND ss.dateschedule < ?", [$d1, $fineLezioni])[0];
$m['presenze'] = (int) $l['presenti'];
$m['perse'] = (int) $l['perse'];
$m['persone'] = (int) $l['persone'];
$m['perse_pct'] = pctv($m['perse'], $m['presenze'] + $m['perse']);
// --- Riempimento (lezioni svolte con capienza)
$sess = q(
$pdo,
"SELECT ss.idserviceschedule, s.idservice, s.servicename, s.day, s.time, s.colorclass, s.maxcapacity,
COUNT(CASE WHEN bc.status = 'booked' THEN 1 END) AS occupati
FROM serviceschedule ss
JOIN service s ON s.idservice = ss.idservice
LEFT JOIN bookingclass bc ON bc.idserviceschedule = ss.idserviceschedule
WHERE ss.dateschedule >= ? AND ss.dateschedule < ? AND s.maxcapacity > 0
GROUP BY ss.idserviceschedule, s.idservice, s.servicename, s.day, s.time, s.colorclass, s.maxcapacity",
[$d1, $fineLezioni]
);
$cap = $occ = $piene = 0;
$perClasse = [];
foreach ($sess as $s) {
$c = (int) $s['maxcapacity'];
$b = (int) $s['occupati'];
$cap += $c;
$occ += min($b, $c);
if ($b >= $c) $piene++;
$id = (int) $s['idservice'];
$perClasse[$id] ??= ['nome' => $s['servicename'], 'quando' => giornoIt(trim($s['day'] . ' ' . $s['time'])), 'colore' => $s['colorclass'] ?: '#1ebf73', 'cap' => 0, 'occ' => 0, 'lezioni' => 0];
$perClasse[$id]['cap'] += $c;
$perClasse[$id]['occ'] += min($b, $c);
$perClasse[$id]['lezioni']++;
}
foreach ($perClasse as &$pc) $pc['fill'] = pctv($pc['occ'], $pc['cap']);
unset($pc);
$m['lezioni_svolte'] = count($sess);
$m['riempimento'] = pctv($occ, $cap);
$m['piene_pct'] = pctv($piene, count($sess));
$m['classi'] = $perClasse;
// Classi "deboli": almeno 2 lezioni svolte e riempimento sotto soglia
$m['classi_deboli'] = array_values(array_filter($perClasse, fn($c) => $c['lezioni'] >= 2 && $c['fill'] !== null && $c['fill'] < $sogliaDebole));
// --- Cancellazioni (storico) — la tabella esiste solo dopo la migrazione
try {
$c = q($pdo, "SELECT COUNT(*) AS tot,
COALESCE(SUM(TIMESTAMPDIFF(MINUTE, c.cancelled_at, COALESCE(ss.dateschedule, c.bookingstart)) < 1440), 0) AS last_minute
FROM bookingclass_cancellations c
LEFT JOIN serviceschedule ss ON ss.idserviceschedule = c.idserviceschedule
WHERE c.cancelled_at >= ? AND c.cancelled_at < ?", [$d1, $d2])[0];
$m['canc'] = (int) $c['tot'];
$m['canc_pct'] = pctv($m['canc'], $m['presenze'] + $m['perse'] + $m['canc']);
$m['last_minute_pct'] = pctv((int) $c['last_minute'], $m['canc']);
} catch (PDOException $e) {
$m['canc'] = null;
$m['canc_pct'] = null;
$m['last_minute_pct'] = null;
}
// --- Rinnovo: chi ha finito un pacchetto ne ha comprato un altro?
// Contano i pacchetti scaduti da almeno $grazia giorni (prima non è ancora "mancato rinnovo"),
// quindi la finestra è spostata indietro di $grazia giorni. Così un pacchetto da 12 lezioni
// (circa 3 mesi) viene valutato solo quando è davvero finito, qualunque sia il periodo scelto.
// Rinnovato = un nuovo ordine dopo quello scaduto, entro $grazia giorni dalla scadenza.
$g1 = date('Y-m-d', strtotime(substr($d1, 0, 10) . " -{$grazia} days"));
$g2 = date('Y-m-d', strtotime(substr($d2, 0, 10) . " -{$grazia} days"));
$scad = q($pdo, "SELECT o.iduser,
MAX(EXISTS(SELECT 1 FROM orderbook o2
WHERE o2.iduser = o.iduser AND o2.idorderbook <> o.idorderbook
AND o2.order_date_created > o.order_date_created
AND o2.order_date_created <= DATE_ADD(o.expireon, INTERVAL ? DAY))) AS riacquistato
FROM orderbook o
WHERE o.iduser IS NOT NULL AND o.expireon >= ? AND o.expireon < ?
AND o.expireon <= DATE_SUB(CURDATE(), INTERVAL ? DAY)
GROUP BY o.iduser", [$grazia, $g1, $g2, $grazia]);
$m['scaduti'] = count($scad);
$m['riacquisto'] = pctv(count(array_filter($scad, fn($r) => (int) $r['riacquistato'] === 1)), count($scad));
// --- Lezioni rimaste inutilizzate nei pacchetti scaduti
$u = q(
$pdo,
"SELECT COALESCE(SUM(o.nticket), 0) AS comprate,
COALESCE(SUM(LEAST(o.nticket, (SELECT COUNT(*) FROM bookingclass bc
WHERE bc.idorder = o.idorderbook AND bc.status NOT IN ('cancelled', 'waiting')))), 0) AS usate
FROM orderbook o
WHERE o.expireon >= ? AND o.expireon < ? AND o.expireon < CURDATE() AND o.nticket > 0",
[substr($d1, 0, 10), substr($d2, 0, 10)]
)[0];
$m['pacchetti_lezioni'] = (int) $u['comprate'];
$m['non_usate'] = (int) $u['comprate'] - (int) $u['usate'];
$m['non_usate_pct'] = pctv($m['non_usate'], (int) $u['comprate']);
return $m;
}
$d1 = $inizio->format('Y-m-d 00:00:00');
$d2 = $fine->format('Y-m-d 00:00:00');
$d1p = $inizioP->format('Y-m-d 00:00:00');
$A = metriche($pdo, $d1, $d2, $now, $SOGLIE['soglia_debole'], $SOGLIE['giorni_rinnovo']);
$P = metriche($pdo, $d1p, $d1, $now, $SOGLIE['soglia_debole'], $SOGLIE['giorni_rinnovo']);
// Vendite e presenze sono stagionali (settembre pieno, estate vuota): se ci sono dati,
// le confrontiamo con lo STESSO periodo dell'anno scorso invece che con quello appena prima.
$d1y = date('Y-m-d H:i:s', strtotime($d1 . ' -1 year'));
$d2y = date('Y-m-d H:i:s', strtotime($d2 . ' -1 year'));
$primoOrdine = q1($pdo, "SELECT MIN(order_date_created) FROM orderbook");
$haAnnoPrima = $primoOrdine && $primoOrdine <= substr($d1y, 0, 10);
$Y = $haAnnoPrima ? metriche($pdo, $d1y, $d2y, $d2y, $SOGLIE['soglia_debole'], $SOGLIE['giorni_rinnovo']) : $P;
$etichettaY = $haAnnoPrima ? 'stesso periodo ' . date('Y', strtotime($d1y)) : 'periodo prima';
/** Abbonati con un pacchetto valido in una certa data (non dipende dalla durata dei pacchetti). */
function abbonati(PDO $pdo, string $data): int
{
return (int) q1($pdo, "SELECT COUNT(DISTINCT iduser) FROM orderbook
WHERE iduser IS NOT NULL AND order_date_created <= ? AND expireon >= ?", [$data, $data]);
}
$abbOggi = abbonati($pdo, date('Y-m-d'));
// Numeri di andamento (senza giudizio)
$ultimoAnno = q($pdo, "SELECT COALESCE(SUM(nticket), 0) AS lezioni, COUNT(*) AS ordini
FROM orderbook WHERE order_date_created > DATE_SUB(CURDATE(), INTERVAL 12 MONTH)")[0];
$nuoviAnno = (int) q1($pdo, "SELECT COUNT(*) FROM (SELECT iduser, MIN(order_date_created) AS primo FROM orderbook
WHERE iduser IS NOT NULL GROUP BY iduser) t WHERE t.primo > DATE_SUB(CURDATE(), INTERVAL 12 MONTH)");
$mesiDati = max(1, min(12, (int) ceil((time() - strtotime((string) (q1($pdo, "SELECT MIN(order_date_created) FROM orderbook") ?: date('Y-m-d')))) / (86400 * 30.4))));
$mesiPeriodo = $giorni / 30.4;
// Lezioni ancora da fare nei pacchetti validi oggi = riempimento "già venduto" delle prossime settimane
$portafoglio = (int) q1($pdo, "SELECT COALESCE(SUM(GREATEST(o.nticket - (SELECT COUNT(*) FROM bookingclass bc
WHERE bc.idorder = o.idorderbook AND bc.status NOT IN ('cancelled', 'waiting')
AND bc.bookingstart <= NOW()), 0)), 0)
FROM orderbook o WHERE o.expireon >= CURDATE() AND o.nticket > 0");
$info = [
['fa-ticket-alt', 'Lezioni vendute', n0($A['vendute']), n0($A['ordini']) . ' ordini · media ultimo anno: ' . n0((int) $ultimoAnno['lezioni'] / $mesiDati * $mesiPeriodo) . ' in un periodo così'],
['fa-user-plus', 'Nuovi clienti', n0($A['nuovi']), 'media ultimo anno: ' . number_format($nuoviAnno / $mesiDati, 1, ',', '.') . ' al mese'],
['fa-users', 'Persone in sala', n0($A['persone']), 'almeno una lezione frequentata nel periodo'],
['fa-layer-group', 'Lezioni già vendute da fare', n0($portafoglio), 'nei pacchetti validi oggi: è il riempimento delle prossime settimane'],
];
$abbPrima = $haAnnoPrima ? abbonati($pdo, date('Y-m-d', strtotime('-1 year'))) : abbonati($pdo, substr($d1, 0, 10));
// --- Certificati (situazione di oggi)
$cert = q($pdo, "SELECT COUNT(*) AS attivi,
COALESCE(SUM(c.scad >= CURDATE()), 0) AS validi
FROM (SELECT DISTINCT iduser FROM orderbook WHERE expireon >= CURDATE() AND iduser IS NOT NULL) a
LEFT JOIN (SELECT iduser, MAX(expirydatedocument) AS scad FROM certificateuserprofile GROUP BY iduser) c
ON c.iduser = a.iduser")[0];
$certPct = pctv((int) $cert['validi'], (int) $cert['attivi']);
$certMancanti = (int) $cert['attivi'] - (int) $cert['validi'];
/* =========================================================== INDICATORI */
$ind = [];
/** Aggiunge un indicatore. $testi = [verde, giallo, rosso] con il consiglio per ogni stato. */
$add = function (string $titolo, string $icona, string $valore, string $stato, ?string $confronto, array $testi, string $dettaglio = '') use (&$ind) {
$i = ['verde' => 0, 'giallo' => 1, 'rosso' => 2][$stato] ?? null;
$ind[] = [
'titolo' => $titolo,
'icona' => $icona,
'valore' => $valore,
'stato' => $stato,
'confronto' => $confronto,
'testo' => $i === null ? 'Dati non ancora sufficienti in questo periodo.' : $testi[$i],
'dettaglio' => $dettaglio,
];
};
/** Testo del confronto con il periodo precedente. */
$conf = function ($cur, $prev, string $unita = '', bool $punti = false, string $rif = 'prima'): ?string {
if ($prev === null || $cur === null) return null;
$tail = ' (' . $rif . ': ' . n0($prev) . $unita . ')';
if ($punti) {
$d = round((float) $cur - (float) $prev, 1);
if (abs($d) < 0.5) return '= stabile' . $tail;
return ($d > 0 ? '▲ +' : '▼ ') . number_format($d, 0, ',', '.') . ' punti' . $tail;
}
$d = delta($cur, $prev);
if ($d === null) return $rif . ': ' . n0($prev);
if (abs($d) < 1) return '= stabile' . $tail;
return ($d > 0 ? '▲ +' : '▼ ') . number_format($d, 0, ',', '.') . '%' . $tail;
};
$trendStato = function ($cur, $prev) use ($SOGLIE): string {
if ($prev === null || (float) $prev == 0.0) return $cur > 0 ? 'verde' : 'grigio';
return semaforo(delta($cur, $prev), $SOGLIE['trend'], true);
};
// 1. Riempimento
$add(
'Riempimento delle lezioni',
'fa-chart-pie',
$A['riempimento'] === null ? '–' : n0($A['riempimento']) . '%',
semaforo($A['riempimento'], $SOGLIE['riempimento'], true),
$conf($A['riempimento'], $P['riempimento'], '%', true),
[
'Le sale sono ben piene: ottimo equilibrio tra orari e domanda.',
'C\'è spazio libero: promuovi le classi meno piene o valuta di accorparle.',
'Troppi posti vuoti: rivedi orari e numero di classi, o lancia una promozione mirata.',
],
n0($A['lezioni_svolte']) . ' lezioni svolte · ' . n0($A['piene_pct'] ?? 0) . '% al completo'
);
// 2. Abbonati attivi oggi (indipendente dalla durata dei pacchetti)
$add(
'Abbonati attivi',
'fa-id-card',
n0($abbOggi),
$trendStato($abbOggi, $abbPrima),
$conf($abbOggi, $abbPrima, '', false, $haAnnoPrima ? 'un anno fa' : 'inizio periodo'),
[
'Il numero di persone con un pacchetto valido tiene o cresce.',
'Ci sono meno abbonati attivi: punta sui rinnovi di chi è in scadenza.',
'Gli abbonati attivi sono in forte calo: è l\'indicatore più importante da recuperare.',
],
'con un pacchetto valido oggi'
);
/*
* Vendite, nuovi clienti e persone in sala NON hanno il semaforo:
* con pacchetti da 12 lezioni (circa 3 mesi) chi compra non riordina per mesi,
* quindi le vendite di un periodo salgono e scendono a ondate anche se la scuola va bene,
* e in un bacino di paese i nuovi clienti sono pochi per natura.
* Li mostriamo come numeri di andamento (con la media mensile dell'ultimo anno come riferimento).
* Lo stato di salute lo danno "Abbonati attivi" e "Clienti che rinnovano".
*/
// 5. Riacquisto
$add(
'Clienti che rinnovano',
'fa-rotate',
$A['riacquisto'] === null ? '–' : n0($A['riacquisto']) . '%',
semaforo($A['riacquisto'], $SOGLIE['riacquisto'], true),
$conf($A['riacquisto'], $P['riacquisto'], '%', true),
[
'La maggior parte di chi finisce un pacchetto ne compra un altro: ottima fidelizzazione.',
'Una parte non rinnova: un promemoria prima della scadenza può fare la differenza.',
'Molti non rinnovano: chiedi un riscontro a chi non torna e prepara un\'offerta di rinnovo.',
],
'entro ' . $SOGLIE['giorni_rinnovo'] . ' giorni dalla scadenza · ' . n0($A['scaduti']) . ' pacchetti finiti, valutati solo dopo ' . $SOGLIE['giorni_rinnovo'] . ' giorni'
);
// 6. Lezioni perse
$add(
'Lezioni perse',
'fa-wind',
$A['perse_pct'] === null ? '–' : n0($A['perse_pct']) . '%',
semaforo($A['perse_pct'], $SOGLIE['perse'], false),
$conf($A['perse_pct'], $P['perse_pct'], '%', true),
[
'Quasi tutti si presentano alle lezioni prenotate.',
'Qualche assenza di troppo: un promemoria il giorno prima aiuta.',
'Molte assenze: posti bloccati e clienti scontenti. Valuta promemoria e regole più chiare.',
],
n0($A['perse']) . ' perse su ' . n0($A['presenze'] + $A['perse']) . ' prenotazioni'
);
// 7. Cancellazioni
if ($A['canc'] !== null) {
$add(
'Cancellazioni',
'fa-ban',
n0($A['canc_pct'] ?? 0) . '%',
semaforo($A['canc_pct'], $SOGLIE['cancellazioni'], false),
$conf($A['canc_pct'], $P['canc_pct'], '%', true),
[
'Poche cancellazioni: le prenotazioni sono affidabili.',
'Una parte delle prenotazioni viene cancellata: controlla se riguarda sempre le stesse classi o persone.',
'Troppe cancellazioni: i posti si liberano tardi e restano vuoti.',
],
n0($A['canc']) . ' cancellazioni nel periodo'
);
$add(
'Cancellazioni all\'ultimo',
'fa-stopwatch',
$A['last_minute_pct'] === null ? '–' : n0($A['last_minute_pct']) . '%',
semaforo($A['last_minute_pct'], $SOGLIE['last_minute'], false),
$conf($A['last_minute_pct'], $P['last_minute_pct'], '%', true),
[
'Chi cancella lo fa con anticipo: il posto si può riassegnare.',
'Una parte cancella a ridosso della lezione: ricorda le regole di cancellazione.',
'Molte cancellazioni con meno di 24 ore: posti persi. Valuta regole più rigide.',
],
'meno di 24 ore prima della lezione'
);
}
// 8. Lezioni non usate nei pacchetti scaduti
$add(
'Lezioni non usate',
'fa-hourglass-end',
$A['non_usate_pct'] === null ? '–' : n0($A['non_usate_pct']) . '%',
semaforo($A['non_usate_pct'], $SOGLIE['non_usate'], false),
$conf($A['non_usate_pct'], $P['non_usate_pct'], '%', true),
[
'I clienti usano quasi tutte le lezioni comprate prima della scadenza.',
'Alcune lezioni scadono senza essere usate: avvisa chi è vicino alla scadenza.',
'Molte lezioni scadono inutilizzate: rischio di clienti scontenti che non rinnovano.',
],
n0($A['non_usate']) . ' lezioni su ' . n0($A['pacchetti_lezioni']) . ' nei pacchetti scaduti'
);
// 9. Classi deboli
$nDeboli = count($A['classi_deboli']);
$add(
'Classi poco frequentate',
'fa-person-chalkboard',
n0($nDeboli),
$A['lezioni_svolte'] > 0 ? semaforo($nDeboli, $SOGLIE['classi_deboli'], false) : 'grigio',
$conf($nDeboli, count($P['classi_deboli'])),
[
'Tutte le classi hanno una buona partecipazione.',
'Qualche classe fatica: valuta cambio orario, promozione o accorpamento.',
'Diverse classi sono sotto il ' . $SOGLIE['soglia_debole'] . '%: conviene rivedere il calendario.',
],
$nDeboli ? implode(', ', array_map(fn($c) => $c['nome'] . ($c['quando'] ? ' (' . $c['quando'] . ')' : ''), array_slice($A['classi_deboli'], 0, 3))) . ($nDeboli > 3 ? '…' : '') : 'sotto il ' . $SOGLIE['soglia_debole'] . '% di riempimento'
);
// 10. Certificati
$add(
'Certificati medici',
'fa-file-medical',
$certPct === null ? '–' : n0($certPct) . '%',
semaforo($certPct, $SOGLIE['certificati'], true),
null,
[
'Quasi tutti gli abbonati sono in regola.',
'Alcuni abbonati hanno il certificato scaduto o mancante: manda il promemoria.',
'Troppi abbonati senza certificato valido: è un rischio per la scuola.',
],
$certMancanti > 0 ? n0($certMancanti) . ' abbonati attivi da sistemare' : 'abbonati attivi oggi'
);
/* =========================================================== GIUDIZIO COMPLESSIVO */
$conta = ['verde' => 0, 'giallo' => 0, 'rosso' => 0, 'grigio' => 0];
foreach ($ind as $i) $conta[$i['stato']]++;
$valutati = $conta['verde'] + $conta['giallo'] + $conta['rosso'];
$punteggio = $valutati ? (int) round(($conta['verde'] * 100 + $conta['giallo'] * 50) / $valutati) : null;
if ($punteggio === null) {
$statoGen = 'grigio';
$frase = 'Non ci sono ancora abbastanza dati in questo periodo.';
} elseif ($conta['rosso'] >= 3 || $punteggio < 50) {
$statoGen = 'rosso';
$frase = 'Ci sono diversi punti critici: conviene intervenire subito su quelli in rosso.';
} elseif ($conta['rosso'] > 0 || $punteggio < 75) {
$statoGen = 'giallo';
$frase = 'La scuola va abbastanza bene, ma alcuni aspetti meritano attenzione.';
} else {
$statoGen = 'verde';
$frase = 'La scuola è in salute: la maggior parte degli indicatori è positiva.';
}
$gruppi = [
'verde' => ['Cosa va bene', 'fa-circle-check'],
'giallo' => ['Da migliorare', 'fa-circle-exclamation'],
'rosso' => ['Cosa non va', 'fa-circle-xmark'],
];
// Classi migliori / peggiori
$classiOrd = array_values(array_filter($A['classi'], fn($c) => $c['lezioni'] >= 2 && $c['fill'] !== null));
usort($classiOrd, fn($a, $b) => $b['fill'] <=> $a['fill']);
$migliori = array_slice($classiOrd, 0, 3);
$peggiori = array_slice(array_reverse($classiOrd), 0, 3);
$periodoTxt = $inizio->format('d/m/Y') . ' – ' . $oggi->format('d/m/Y');
$prevTxt = $inizioP->format('d/m/Y') . ' – ' . (clone $inizio)->modify('-1 day')->format('d/m/Y');
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>YogiBook - Sommario</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta content="YogiBook - Prenotazione facile YogaSoul" name="description" />
<link rel="shortcut icon" href="assets/images/favicon.ico">
<link href="assets/css/bootstrap.min.css" id="bootstrap-style" rel="stylesheet" type="text/css" />
<link href="assets/css/icons.min.css" rel="stylesheet" type="text/css" />
<link href="assets/css/app.min.css" id="app-style" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<style>
:root {
--verde: #1a9e5f;
--verde-bg: #e7f7ef;
--giallo: #d49a00;
--giallo-bg: #fff6dc;
--rosso: #d64545;
--rosso-bg: #fdecec;
--grigio: #9ca3af;
--grigio-bg: #f3f4f6;
--ink: #1f2937;
--muted: #6b7280;
--sabbia: #E1D0B8;
}
.sm-card {
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 22px rgba(0, 0, 0, .06);
}
/* ---- Periodo */
.sm-period {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
.sm-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.sm-chip {
border: 1px solid #e2e4e9;
background: #fff;
color: #374151;
font-size: 13px;
font-weight: 600;
padding: 6px 14px;
border-radius: 20px;
text-decoration: none;
}
.sm-chip:hover {
border-color: #b08d57;
color: #374151;
}
.sm-chip.active {
background: var(--sabbia);
border-color: var(--sabbia);
color: #5a4630;
}
.sm-period-txt {
font-size: 13px;
color: var(--muted);
}
/* ---- Hero */
.sm-hero {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 28px;
align-items: center;
padding: 26px 30px;
margin-bottom: 22px;
position: relative;
overflow: hidden;
}
.sm-hero::after {
content: '';
position: absolute;
right: -60px;
top: -60px;
width: 240px;
height: 240px;
border-radius: 50%;
opacity: .08;
background: var(--c);
}
.sm-light {
background: #1f2937;
border-radius: 22px;
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 9px;
box-shadow: inset 0 0 0 3px #374151;
}
.sm-light span {
width: 38px;
height: 38px;
border-radius: 50%;
background: #374151;
transition: all .3s;
}
.sm-light .r.on {
background: #ff5b5b;
box-shadow: 0 0 18px #ff5b5b;
}
.sm-light .g.on {
background: #ffc531;
box-shadow: 0 0 18px #ffc531;
}
.sm-light .v.on {
background: #33d17a;
box-shadow: 0 0 18px #33d17a;
}
.sm-kicker {
font-size: 12px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--muted);
}
.sm-hero h2 {
font-size: 26px;
font-weight: 800;
color: var(--ink);
margin: 4px 0 6px;
}
.sm-hero p {
color: #4b5563;
margin: 0;
font-size: 15px;
}
.sm-score {
text-align: center;
}
.sm-ring {
--p: 0;
margin: 0 auto;
width: 120px;
height: 120px;
border-radius: 50%;
display: grid;
place-items: center;
background: conic-gradient(var(--c) calc(var(--p) * 1%), #eef0f3 0);
}
.sm-ring div {
width: 92px;
height: 92px;
border-radius: 50%;
background: #fff;
display: grid;
place-items: center;
}
.sm-ring strong {
font-size: 30px;
font-weight: 800;
color: var(--ink);
line-height: 1;
}
.sm-ring small {
display: block;
font-size: 11px;
color: var(--muted);
}
.sm-counts {
display: flex;
gap: 14px;
justify-content: center;
margin-top: 10px;
font-size: 13px;
font-weight: 700;
}
.sm-counts span::before {
content: '';
display: inline-block;
width: 9px;
height: 9px;
border-radius: 50%;
margin-right: 5px;
background: var(--c);
}
/* ---- Colonne */
.sm-col-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 800;
color: var(--c);
margin: 4px 0 12px;
}
.sm-col-head .n {
background: var(--bg);
color: var(--c);
border-radius: 20px;
padding: 1px 10px;
font-size: 12px;
}
.sm-empty {
border: 2px dashed #e5e7eb;
border-radius: 14px;
padding: 18px;
color: var(--grigio);
text-align: center;
font-size: 13px;
}
.sm-ind {
padding: 16px 18px;
margin-bottom: 14px;
border-left: 5px solid var(--c);
}
.sm-ind-top {
display: flex;
align-items: center;
gap: 12px;
}
.sm-ind-ico {
width: 40px;
height: 40px;
border-radius: 12px;
display: grid;
place-items: center;
background: var(--bg);
color: var(--c);
font-size: 16px;
flex: 0 0 40px;
}
.sm-ind-title {
font-size: 13px;
font-weight: 700;
color: #4b5563;
}
.sm-ind-val {
font-size: 26px;
font-weight: 800;
color: var(--ink);
line-height: 1.1;
}
.sm-dot {
margin-left: auto;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--c);
box-shadow: 0 0 0 4px var(--bg);
flex: 0 0 14px;
}
.sm-ind-det {
font-size: 12px;
color: var(--muted);
margin-top: 8px;
}
.sm-ind-cmp {
font-size: 12px;
font-weight: 700;
margin-top: 4px;
color: #4b5563;
}
.sm-ind-txt {
font-size: 13px;
color: #374151;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #f1f2f4;
}
.st-verde {
--c: var(--verde);
--bg: var(--verde-bg);
}
.st-giallo {
--c: var(--giallo);
--bg: var(--giallo-bg);
}
.st-rosso {
--c: var(--rosso);
--bg: var(--rosso-bg);
}
.st-grigio {
--c: var(--grigio);
--bg: var(--grigio-bg);
}
/* ---- Classi */
.sm-classes {
padding: 20px 22px;
margin-bottom: 22px;
}
.sm-classes h5 {
font-size: 15px;
font-weight: 800;
color: var(--ink);
margin-bottom: 12px;
}
.sm-cls {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid #f3f4f6;
}
.sm-cls:last-child {
border-bottom: 0;
}
.sm-cls .dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex: 0 0 10px;
}
.sm-cls .nm {
flex: 1;
min-width: 0;
font-weight: 600;
font-size: 14px;
color: #374151;
}
.sm-cls .nm small {
display: block;
font-weight: 400;
color: var(--muted);
font-size: 12px;
}
.sm-bar {
width: 120px;
height: 8px;
border-radius: 10px;
background: #eef0f3;
overflow: hidden;
}
.sm-bar span {
display: block;
height: 100%;
border-radius: 10px;
background: var(--c);
}
.sm-cls .pc {
width: 44px;
text-align: right;
font-weight: 800;
font-size: 14px;
color: var(--ink);
}
.sm-info-head {
font-size: 15px;
font-weight: 800;
color: #1f2937;
margin: 4px 0 12px;
}
.sm-info-head small {
display: block;
font-weight: 400;
font-size: 12px;
color: var(--muted);
margin-top: 2px;
}
.sm-info {
padding: 14px 16px;
display: flex;
gap: 12px;
height: 100%;
--c: #2980b9;
--bg: #e8f1fa;
}
.sm-info .sm-ind-val {
font-size: 22px;
}
.sm-info .sm-ind-det {
margin-top: 2px;
}
.sm-foot {
font-size: 12px;
color: var(--muted);
margin-bottom: 20px;
}
@media (max-width: 767px) {
.sm-hero {
grid-template-columns: 1fr;
text-align: center;
padding: 22px 18px;
}
.sm-light {
flex-direction: row;
justify-self: center;
}
.sm-bar {
width: 70px;
}
}
@media print {
.vertical-menu,
#page-topbar,
.ishorizontal-topbar,
.sm-chips,
footer {
display: none !important;
}
.main-content {
margin-left: 0 !important;
}
.page-content {
padding: 0 !important;
}
.sm-card {
box-shadow: none;
border: 1px solid #e5e7eb;
break-inside: avoid;
}
}
</style>
</head>
<body>
<div id="layout-wrapper">
<header id="page-topbar" class="isvertical-topbar">
<div class="navbar-header">
<div class="d-flex">
<?php include('include/logoarea.php'); ?>
<button type="button" class="btn btn-sm px-3 font-size-24 header-item waves-effect vertical-menu-btn">
<i class="bx bx-menu align-middle"></i>
</button>
<div class="page-title-box align-self-center d-none d-md-block">
<h4 class="page-title mb-0">Sommario</h4>
</div>
</div>
<div class="d-flex">
<?php include('include/languageselection.php'); ?>
<?php include('include/profiletopbar.php'); ?>
</div>
</div>
</header>
<?php include('include/sidebar.php'); ?>
<header class="ishorizontal-topbar">
<div class="navbar-header">
<div class="d-flex"></div>
</div>
<div class="topnav">
<div class="container-fluid">
<nav class="navbar navbar-light navbar-expand-lg topnav-menu"></nav>
</div>
</div>
</header>
<div class="main-content">
<div class="page-content">
<div class="container-fluid">
<!-- Periodo -->
<div class="sm-period">
<div class="sm-chips">
<?php foreach ($periodi as $k => $lbl) : ?>
<a class="sm-chip <?php echo (int) $k === $giorni ? 'active' : ''; ?>" href="?p=<?php echo e($k); ?>"><?php echo e($lbl); ?></a>
<?php endforeach; ?>
<a class="sm-chip" href="#" onclick="window.print();return false;"><i class="fas fa-print me-1"></i> Stampa</a>
</div>
<div class="sm-period-txt">
<i class="far fa-calendar me-1"></i> <strong><?php echo e($periodoTxt); ?></strong>
· confronto con <?php echo e($prevTxt); ?><?php if ($haAnnoPrima) : ?> (vendite e presenze: <?php echo e($etichettaY); ?>)<?php endif; ?>
</div>
</div>
<!-- Giudizio complessivo -->
<div class="sm-card sm-hero st-<?php echo $statoGen; ?>">
<div class="sm-light" aria-hidden="true">
<span class="r <?php echo $statoGen === 'rosso' ? 'on' : ''; ?>"></span>
<span class="g <?php echo $statoGen === 'giallo' ? 'on' : ''; ?>"></span>
<span class="v <?php echo $statoGen === 'verde' ? 'on' : ''; ?>"></span>
</div>
<div>
<div class="sm-kicker">Stato della scuola · <?php echo e($periodi[(string) $giorni]); ?></div>
<h2><?php echo e(['verde' => 'Tutto bene', 'giallo' => 'Bene, con margini di miglioramento', 'rosso' => 'Attenzione', 'grigio' => 'Dati insufficienti'][$statoGen]); ?></h2>
<p><?php echo e($frase); ?></p>
</div>
<div class="sm-score">
<div class="sm-ring" style="--p: <?php echo (int) ($punteggio ?? 0); ?>;">
<div><span><strong><?php echo $punteggio === null ? '–' : $punteggio; ?></strong><small>su 100</small></span></div>
</div>
<div class="sm-counts">
<span class="st-verde"><?php echo $conta['verde']; ?></span>
<span class="st-giallo"><?php echo $conta['giallo']; ?></span>
<span class="st-rosso"><?php echo $conta['rosso']; ?></span>
</div>
</div>
</div>
<!-- Numeri di andamento: senza semaforo -->
<div class="sm-info-head"><i class="fas fa-chart-line me-1"></i> Andamento del periodo <small>numeri da leggere, senza giudizio: con pacchetti lunghi e un bacino di paese le vendite vanno a ondate</small></div>
<div class="row g-3 mb-4">
<?php foreach ($info as [$ic, $tit, $val, $sub]) : ?>
<div class="col-6 col-lg-3">
<div class="sm-card sm-info">
<div class="sm-ind-ico"><i class="fas <?php echo $ic; ?>"></i></div>
<div>
<div class="sm-ind-title"><?php echo e($tit); ?></div>
<div class="sm-ind-val"><?php echo e($val); ?></div>
<div class="sm-ind-det"><?php echo e($sub); ?></div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- Indicatori raggruppati per semaforo -->
<div class="row">
<?php foreach ($gruppi as $stato => [$titolo, $icona]) :
$lista = array_values(array_filter($ind, fn($i) => $i['stato'] === $stato));
?>
<div class="col-lg-4">
<div class="sm-col-head st-<?php echo $stato; ?>">
<i class="fas <?php echo $icona; ?>"></i> <?php echo e($titolo); ?>
<span class="n"><?php echo count($lista); ?></span>
</div>
<?php if (!$lista) : ?>
<div class="sm-empty"><?php echo $stato === 'verde' ? 'Nessun indicatore positivo in questo periodo.' : 'Niente da segnalare qui.'; ?></div>
<?php endif; ?>
<?php foreach ($lista as $i) : ?>
<div class="sm-card sm-ind st-<?php echo $i['stato']; ?>">
<div class="sm-ind-top">
<div class="sm-ind-ico"><i class="fas <?php echo e($i['icona']); ?>"></i></div>
<div>
<div class="sm-ind-title"><?php echo e($i['titolo']); ?></div>
<div class="sm-ind-val"><?php echo e($i['valore']); ?></div>
</div>
<span class="sm-dot" title="<?php echo e($i['stato']); ?>"></span>
</div>
<?php if ($i['dettaglio'] !== '') : ?><div class="sm-ind-det"><?php echo e($i['dettaglio']); ?></div><?php endif; ?>
<?php if ($i['confronto']) : ?><div class="sm-ind-cmp"><?php echo e($i['confronto']); ?></div><?php endif; ?>
<div class="sm-ind-txt"><i class="far fa-lightbulb me-1"></i> <?php echo e($i['testo']); ?></div>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php
$grigi = array_values(array_filter($ind, fn($i) => $i['stato'] === 'grigio'));
if ($grigi) : ?>
<div class="sm-foot"><i class="fas fa-circle-info me-1"></i> Senza dati sufficienti nel periodo: <?php echo e(implode(', ', array_column($grigi, 'titolo'))); ?>.</div>
<?php endif; ?>
<!-- Classi migliori e peggiori -->
<?php if ($classiOrd) : ?>
<div class="row">
<?php foreach ([['Le classi più piene', $migliori], ['Le classi più vuote', $peggiori]] as [$tit, $lista]) : ?>
<div class="col-lg-6">
<div class="sm-card sm-classes">
<h5><?php echo e($tit); ?></h5>
<?php foreach ($lista as $c) :
$st = semaforo($c['fill'], $SOGLIE['riempimento'], true); ?>
<div class="sm-cls st-<?php echo $st; ?>">
<span class="dot" style="background:<?php echo e($c['colore']); ?>"></span>
<div class="nm"><?php echo e($c['nome']); ?><small><?php echo e($c['quando']); ?> · <?php echo n0($c['lezioni']); ?> lezioni</small></div>
<div class="sm-bar"><span style="width:<?php echo min(100, (float) $c['fill']); ?>%"></span></div>
<div class="pc"><?php echo n0($c['fill']); ?>%</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="sm-foot">
Il riempimento e le lezioni perse considerano solo le lezioni già svolte. I rinnovi vengono valutati solo a pacchetto finito.
Per tutti i dettagli apri le <a href="statistiche.php">Statistiche</a>.
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
</div>
</div>
<script src="assets/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="assets/libs/metismenujs/metismenujs.min.js"></script>
<script src="assets/libs/simplebar/simplebar.min.js"></script>
<script src="assets/libs/eva-icons/eva.min.js"></script>
<script src="assets/js/app.js"></script>
</body>
</html>