Files
zibo-dashboard/public/userarea/sales_summary.php
T

2217 lines
78 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
ob_start();
ini_set('display_errors', 1);
error_reporting(E_ALL);
include('include/headscript.php');
require_once __DIR__ . '/class/MagoSalesStats.php';
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// --- Permessi: pagina visibile solo con 'sales.stats.view' ---
if (!userCan('sales.stats.view')) {
if (isset($_GET['action']) || isset($_POST['action'])) {
MagoSalesStats::jsonOut(['success' => false, 'ok' => false, 'message' => 'Non hai i permessi per questa pagina']);
}
while (ob_get_level()) ob_end_clean();
http_response_code(403);
echo '<div style="font-family:sans-serif;padding:60px;text-align:center;"><h3>Accesso non consentito</h3><p>Non hai i permessi per visualizzare questa pagina.</p><a href="production_dashboard.php">Torna alla Dashboard</a></div>';
exit;
}
$canDeliveries = userCan('sales.deliveries.view');
/* ============================================================
* AJAX
* ============================================================ */
/** Importo breve per le frasi: 1,23 M€ / 456,7 k€ / 890 € */
function eurShort($v)
{
$v = (float)$v;
$a = abs($v);
if ($a >= 1e6) return number_format($v / 1e6, 2, ',', '.') . ' M€';
if ($a >= 1e3) return number_format($v / 1e3, 1, ',', '.') . ' k€';
return number_format($v, 0, ',', '.') . ' €';
}
function pctTxt($p, $dec = 0)
{
return ($p >= 0 ? '+' : '') . number_format($p, $dec, ',', '.') . '%';
}
function pctOf($cur, $prev)
{
return $prev ? ($cur - $prev) / abs($prev) * 100 : null;
}
$action = $_GET['action'] ?? '';
if ($action === 'filters') {
MagoSalesStats::jsonOut(MagoSalesStats::filterOptions($pdo));
}
if ($action === 'data') {
try {
$curY = (int)date('Y');
$from = MagoSalesStats::baseFrom();
$val = "CASE WHEN x.status <> 'annullato' THEN x.taxable_amount ELSE 0 END";
$T = []; // tessere (indicatori)
// ---------------- ORDINATO ----------------
$ordYears = MagoSalesStats::fetchAllQ($pdo, "
SELECT YEAR(x.order_date) AS y, SUM($val) AS full,
SUM(CASE WHEN DATE_FORMAT(x.order_date, '%m-%d') <= DATE_FORMAT(CURDATE(), '%m-%d') THEN $val ELSE 0 END) AS ytd
FROM $from WHERE x.order_date >= '" . ($curY - 3) . "-01-01' GROUP BY y
", []);
$oy = [];
foreach ($ordYears as $r) $oy[(int)$r['y']] = $r;
$ordCur = (float)($oy[$curY]['ytd'] ?? 0);
$ordPrev = (float)($oy[$curY - 1]['ytd'] ?? 0);
$ordFc = MagoSalesStats::yearForecast($ordYears, $curY);
$since13 = date('Y-m-01', strtotime('first day of -12 months'));
$ordMonths = MagoSalesStats::fetchAllQ($pdo, "
SELECT DATE_FORMAT(x.order_date, '%Y-%m') AS ym, SUM($val) AS v
FROM $from WHERE x.order_date >= '$since13' GROUP BY ym ORDER BY ym
", []);
$ord90 = (float)MagoSalesStats::fetchAllQ($pdo, "SELECT COALESCE(SUM($val),0) AS v FROM $from WHERE x.order_date > CURDATE() - INTERVAL 90 DAY", [])[0]['v'];
// ---------------- FATTURATO ----------------
$invOk = false;
$invCur = $invPrev = $inv90 = $inv12 = $invP12 = $cn12 = 0.0;
$invFc = null;
$invMonths = [];
$iFrom = MagoSalesStats::invoiceFrom();
try {
$invOk = (bool)$pdo->query("SELECT 1 FROM mago_sale_doc LIMIT 1")->fetchColumn();
} catch (Throwable $e) {
$invOk = false;
}
if ($invOk) {
$invYears = MagoSalesStats::fetchAllQ($pdo, "
SELECT YEAR(i.document_date) AS y, SUM(i.revenue) AS full,
SUM(CASE WHEN DATE_FORMAT(i.document_date, '%m-%d') <= DATE_FORMAT(CURDATE(), '%m-%d') THEN i.revenue ELSE 0 END) AS ytd
FROM $iFrom WHERE i.document_date >= '" . ($curY - 3) . "-01-01' GROUP BY y
", []);
$iy = [];
foreach ($invYears as $r) $iy[(int)$r['y']] = $r;
$invCur = (float)($iy[$curY]['ytd'] ?? 0);
$invPrev = (float)($iy[$curY - 1]['ytd'] ?? 0);
$invFc = MagoSalesStats::yearForecast($invYears, $curY);
$invMonths = MagoSalesStats::fetchAllQ($pdo, "
SELECT DATE_FORMAT(i.document_date, '%Y-%m') AS ym, SUM(i.revenue) AS v
FROM $iFrom WHERE i.document_date >= '$since13' GROUP BY ym ORDER BY ym
", []);
$r = MagoSalesStats::fetchAllQ($pdo, "
SELECT COALESCE(SUM(CASE WHEN i.document_date > CURDATE() - INTERVAL 90 DAY THEN i.revenue END), 0) AS v90,
COALESCE(SUM(CASE WHEN i.document_date > CURDATE() - INTERVAL 365 DAY THEN i.revenue END), 0) AS v12,
COALESCE(SUM(CASE WHEN i.document_date <= CURDATE() - INTERVAL 365 DAY THEN i.revenue END), 0) AS p12,
COALESCE(SUM(CASE WHEN i.document_date > CURDATE() - INTERVAL 365 DAY AND i.is_credit_note = 1 THEN -i.revenue END), 0) AS cn12,
COALESCE(SUM(CASE WHEN i.document_date > CURDATE() - INTERVAL 365 DAY AND i.is_credit_note = 0 THEN i.revenue END), 0) AS gross12
FROM $iFrom WHERE i.document_date > CURDATE() - INTERVAL 730 DAY
", [])[0];
$inv90 = (float)$r['v90'];
$inv12 = (float)$r['v12'];
$invP12 = (float)$r['p12'];
$cn12 = (float)$r['cn12'];
$gross12 = (float)$r['gross12'];
}
// 1) FATTURATO DELL'ANNO
if ($invOk && $invPrev > 0) {
$p = pctOf($invCur, $invPrev);
$T[] = [
'key' => 'invYtd',
'group' => 'Risultati',
'title' => "Fatturato $curY",
'value' => eurShort($invCur),
'light' => MagoSalesStats::light($p, 'growth'),
'delta' => $p,
'sub' => 'ad oggi · ' . ($curY - 1) . ' stessa data ' . eurShort($invPrev),
'verdict' => $p >= 0 ? 'Stiamo fatturando ' . pctTxt($p) . ' rispetto all\'anno scorso' : 'Fatturato sotto l\'anno scorso del ' . number_format(abs($p), 0, ',', '.') . '%',
'spark' => 'inv',
'link' => 'sales_orders_years.php'
];
} else {
$T[] = [
'key' => 'invYtd',
'group' => 'Risultati',
'title' => "Fatturato $curY",
'value' => $invOk ? eurShort($invCur) : 'n.d.',
'light' => 'grey',
'sub' => $invOk ? 'nessun confronto con l\'anno scorso' : 'fatture non ancora sincronizzate',
'verdict' => '',
'link' => 'sales_orders_years.php'
];
}
// 2) ORDINATO DELL'ANNO
$p = pctOf($ordCur, $ordPrev);
$T[] = [
'key' => 'ordYtd',
'group' => 'Risultati',
'title' => "Ordinato $curY",
'value' => eurShort($ordCur),
'light' => MagoSalesStats::light($p, 'growth'),
'delta' => $p,
'sub' => 'ad oggi · ' . ($curY - 1) . ' stessa data ' . eurShort($ordPrev),
'verdict' => $p === null ? '' : ($p >= 0 ? 'Entrano più ordini dell\'anno scorso (' . pctTxt($p) . ')' : 'Entrano meno ordini dell\'anno scorso (' . pctTxt($p) . ')'),
'spark' => 'ord',
'link' => 'sales_orders_years.php'
];
// 3) PREVISIONE DI FINE ANNO (fatturato, altrimenti ordinato)
$fc = ($invOk && $invFc && $invFc['forecast'] !== null) ? $invFc : $ordFc;
$fcWhat = ($fc === $invFc) ? 'fatturato' : 'ordinato';
if ($fc && $fc['forecast'] !== null && $fc['prev_full']) {
$p = pctOf($fc['forecast'], $fc['prev_full']);
$T[] = [
'key' => 'forecast',
'group' => 'Risultati',
'title' => "Chiusura $curY prevista",
'value' => eurShort($fc['forecast']),
'light' => MagoSalesStats::light($p, 'growth'),
'delta' => $p,
'sub' => "$fcWhat · forbice " . eurShort($fc['low']) . ' – ' . eurShort($fc['high']) . ' · ' . ($curY - 1) . ': ' . eurShort($fc['prev_full']),
'verdict' => $p >= 0 ? 'Se si continua così chiudiamo sopra l\'anno scorso' : 'Se si continua così chiudiamo sotto l\'anno scorso',
'link' => 'sales_orders_years.php'
];
} else {
$T[] = [
'key' => 'forecast',
'group' => 'Risultati',
'title' => "Chiusura $curY prevista",
'value' => 'n.d.',
'light' => 'grey',
'sub' => 'servono i dati degli anni precedenti',
'verdict' => '',
'link' => 'sales_orders_years.php'
];
}
// 4) BOOK-TO-BILL
if ($invOk && $inv90 > 0) {
$b2b = $ord90 / $inv90;
$T[] = [
'key' => 'b2b',
'group' => 'Futuro',
'title' => 'Book-to-bill 90 gg',
'value' => number_format($b2b, 2, ',', '.'),
'light' => MagoSalesStats::light($b2b, 'b2b'),
'sub' => 'ordinato ' . eurShort($ord90) . ' · fatturato ' . eurShort($inv90),
'verdict' => $b2b >= 1.05 ? 'Entra più lavoro di quanto ne esce: il portafoglio cresce' : ($b2b < 0.95 ? 'Esce più lavoro di quanto ne entra: il portafoglio si svuota' : 'Lavoro in entrata e in uscita in equilibrio'),
'spark' => 'b2b',
'link' => 'sales_orders_years.php'
];
} else {
$T[] = [
'key' => 'b2b',
'group' => 'Futuro',
'title' => 'Book-to-bill 90 gg',
'value' => 'n.d.',
'light' => 'grey',
'sub' => $invOk ? 'nessun fatturato negli ultimi 90 giorni' : 'fatture non ancora sincronizzate',
'verdict' => '',
'link' => 'sales_orders_years.php'
];
}
// 5-6) PORTAFOGLIO: copertura e ritardi
$bk = MagoSalesStats::fetchAllQ($pdo, "
SELECT COALESCE(SUM(r.residual_value), 0) AS v, COUNT(*) AS n_lines,
COALESCE(SUM(CASE WHEN r.due_date < CURDATE() THEN r.residual_value END), 0) AS late_v,
SUM(r.due_date < CURDATE()) AS late_n,
COUNT(DISTINCT CASE WHEN r.due_date < CURDATE() THEN r.customer END) AS late_c
FROM " . MagoSalesStats::openLinesFrom() . "
", [])[0];
$weekly = ($invOk && $inv90 > 0 ? $inv90 : $ord90) / (90 / 7);
$weeks = $weekly > 0 ? (float)$bk['v'] / $weekly : null;
$T[] = [
'key' => 'cover',
'group' => 'Futuro',
'title' => 'Lavoro in portafoglio',
'value' => $weeks === null ? 'n.d.' : number_format($weeks, 0, ',', '.') . ' sett.',
'light' => MagoSalesStats::light($weeks, 'cover_weeks'),
'sub' => eurShort($bk['v']) . ' da consegnare · ' . number_format((int)$bk['n_lines'], 0, ',', '.') . ' righe',
'verdict' => $weeks === null ? '' : ($weeks >= 8 ? 'Lavoro assicurato per circa ' . round($weeks) . ' settimane' : ($weeks < 4 ? 'Portafoglio corto: servono ordini nuovi' : 'Portafoglio sufficiente, da tenere d\'occhio')),
'link' => $canDeliveries ? 'sales_orders_deliveries.php' : 'sales_orders_years.php'
];
$latePct = (float)$bk['v'] > 0 ? (float)$bk['late_v'] / (float)$bk['v'] * 100 : 0;
$T[] = [
'key' => 'late',
'group' => 'Clienti e servizio',
'title' => 'Consegne in ritardo',
'value' => number_format($latePct, 0, ',', '.') . '%',
'light' => MagoSalesStats::light($latePct, 'late_pct', true),
'sub' => eurShort($bk['late_v']) . ' · ' . number_format((int)$bk['late_n'], 0, ',', '.') . ' righe · ' . (int)$bk['late_c'] . ' clienti',
'verdict' => $latePct <= 10 ? 'Consegne in linea con le date comunicate' : ($latePct > 25 ? 'Troppa merce oltre la data comunicata ai clienti' : 'Una parte delle consegne è in ritardo'),
'link' => $canDeliveries ? 'sales_orders_deliveries.php' : 'sales_orders_stats.php'
];
// 7) CLIENTI A RISCHIO (dormienti / persi)
$dormRows = MagoSalesStats::fetchAllQ($pdo, "
SELECT x.customer AS code, x.company_name AS name, x.order_date, x.taxable_amount AS v,
x.salesperson, x.area, x.internal_ord_no AS ord_no
FROM $from
WHERE x.status <> 'annullato' AND x.customer <> ''
AND x.order_date >= CURDATE() - INTERVAL " . (int)MagoSalesStats::DORMANT_WINDOW_YEARS . " YEAR
", []);
$dormant = MagoSalesStats::dormantCustomers($dormRows);
$atRisk = array_values(array_filter($dormant, function ($c) {
return $c['level'] !== 'watch';
}));
$riskV = array_sum(array_column($atRisk, 'value12'));
$base12 = $invOk && $inv12 > 0 ? $inv12 : (float)MagoSalesStats::fetchAllQ($pdo, "SELECT COALESCE(SUM($val),0) AS v FROM $from WHERE x.order_date > CURDATE() - INTERVAL 365 DAY", [])[0]['v'];
$riskPct = $base12 > 0 ? $riskV / $base12 * 100 : null;
$T[] = [
'key' => 'risk',
'group' => 'Clienti e servizio',
'title' => 'Clienti a rischio',
'value' => count($atRisk) . ' clienti',
'light' => MagoSalesStats::light($riskPct, 'risk_pct', true),
'sub' => eurShort($riskV) . ' di ordinato annuo · ' . ($riskPct === null ? '' : number_format($riskPct, 1, ',', '.') . '% del giro d\'affari') . ' · ' . (count($dormant) - count($atRisk)) . ' in attenzione',
'verdict' => !$atRisk ? 'Nessun cliente abituale si è fermato' : (MagoSalesStats::light($riskPct, 'risk_pct', true) === 'green' ? 'Pochi clienti abituali fermi, con un peso ridotto sul giro d\'affari' : 'Clienti abituali importanti hanno smesso di ordinare: da richiamare'),
'link' => 'sales_orders_years.php'
];
// 8) CONCENTRAZIONE (primi 5 clienti, ultimi 12 mesi)
if ($invOk && $inv12 > 0) {
$top = MagoSalesStats::fetchAllQ($pdo, "
SELECT i.customer AS code, MAX(i.company_name) AS name, SUM(i.revenue) AS v
FROM $iFrom WHERE i.document_date > CURDATE() - INTERVAL 365 DAY
GROUP BY i.customer ORDER BY v DESC LIMIT 5
", []);
$tot12 = $inv12;
} else {
$top = MagoSalesStats::fetchAllQ($pdo, "
SELECT x.customer AS code, MAX(x.company_name) AS name, SUM($val) AS v
FROM $from WHERE x.order_date > CURDATE() - INTERVAL 365 DAY
GROUP BY x.customer ORDER BY v DESC LIMIT 5
", []);
$tot12 = $base12;
}
$top5 = array_sum(array_map('floatval', array_column($top, 'v')));
$topPct = $tot12 > 0 ? $top5 / $tot12 * 100 : null;
$T[] = [
'key' => 'top5',
'group' => 'Clienti e servizio',
'title' => 'Peso primi 5 clienti',
'value' => $topPct === null ? 'n.d.' : number_format($topPct, 0, ',', '.') . '%',
'light' => MagoSalesStats::light($topPct, 'top5_pct', true),
'sub' => 'del ' . ($invOk ? 'fatturato' : 'ordinato') . ' degli ultimi 12 mesi' . ($top ? ' · 1° ' . $top[0]['name'] : ''),
'verdict' => $topPct === null ? '' : ($topPct <= 40 ? 'Giro d\'affari ben distribuito tra i clienti' : ($topPct > 60 ? 'Dipendiamo molto da pochi clienti' : 'Dipendenza dai clienti principali da tenere d\'occhio')),
'link' => 'sales_orders_years.php',
'list' => array_map(function ($r) use ($tot12) {
return ['code' => $r['code'], 'name' => $r['name'], 'v' => (float)$r['v'], 'pct' => $tot12 > 0 ? (float)$r['v'] / $tot12 * 100 : 0];
}, $top)
];
// 9) BASE CLIENTI (attivi 12 mesi vs 12 precedenti, nuovi)
$cb = MagoSalesStats::fetchAllQ($pdo, "
SELECT COUNT(DISTINCT CASE WHEN x.order_date > CURDATE() - INTERVAL 365 DAY THEN x.customer END) AS cur,
COUNT(DISTINCT CASE WHEN x.order_date <= CURDATE() - INTERVAL 365 DAY THEN x.customer END) AS prev
FROM $from WHERE x.status <> 'annullato' AND x.customer <> '' AND x.order_date > CURDATE() - INTERVAL 730 DAY
", [])[0];
$newC = (int)MagoSalesStats::fetchAllQ($pdo, "
SELECT COUNT(*) AS n FROM (
SELECT x.customer FROM $from WHERE x.status <> 'annullato' AND x.customer <> ''
GROUP BY x.customer HAVING MIN(x.order_date) > CURDATE() - INTERVAL 365 DAY
) t
", [])[0]['n'];
$p = pctOf((int)$cb['cur'], (int)$cb['prev']);
$T[] = [
'key' => 'customers',
'group' => 'Clienti e servizio',
'title' => 'Clienti attivi',
'value' => number_format((int)$cb['cur'], 0, ',', '.'),
'light' => MagoSalesStats::light($p, 'customers'),
'delta' => $p,
'sub' => 'ultimi 12 mesi · prima ' . (int)$cb['prev'] . ' · ' . $newC . ' nuovi',
'verdict' => $p === null ? '' : ($p >= 0 ? 'La base clienti tiene' . ($newC ? " ($newC clienti nuovi)" : '') : 'Stiamo perdendo clienti rispetto all\'anno prima'),
'link' => 'sales_orders_years.php'
];
// 10) NOTE DI CREDITO
if ($invOk && !empty($gross12)) {
$cnPct = $cn12 / $gross12 * 100;
$T[] = [
'key' => 'cn',
'group' => 'Clienti e servizio',
'title' => 'Note di credito',
'value' => number_format($cnPct, 1, ',', '.') . '%',
'light' => MagoSalesStats::light($cnPct, 'cn_pct', true),
'sub' => eurShort($cn12) . ' negli ultimi 12 mesi',
'verdict' => $cnPct <= 1.5 ? 'Poche contestazioni e resi' : ($cnPct > 3 ? 'Troppe note di credito: verificare resi e contestazioni' : 'Note di credito da tenere d\'occhio'),
'link' => 'sales_orders_years.php'
];
} else {
$T[] = [
'key' => 'cn',
'group' => 'Clienti e servizio',
'title' => 'Note di credito',
'value' => 'n.d.',
'light' => 'grey',
'sub' => 'fatture non ancora sincronizzate',
'verdict' => '',
'link' => 'sales_orders_years.php'
];
}
// ---------------- CLIENTI CHE CRESCONO / CALANO (12 mesi vs 12 precedenti) ----------------
$mv = $invOk && $inv12 > 0
? MagoSalesStats::fetchAllQ($pdo, "
SELECT i.customer AS code, MAX(i.company_name) AS name,
SUM(CASE WHEN i.document_date > CURDATE() - INTERVAL 365 DAY THEN i.revenue ELSE 0 END) AS cur,
SUM(CASE WHEN i.document_date <= CURDATE() - INTERVAL 365 DAY THEN i.revenue ELSE 0 END) AS prev
FROM $iFrom WHERE i.document_date > CURDATE() - INTERVAL 730 DAY AND i.customer <> ''
GROUP BY i.customer", [])
: MagoSalesStats::fetchAllQ($pdo, "
SELECT x.customer AS code, MAX(x.company_name) AS name,
SUM(CASE WHEN x.order_date > CURDATE() - INTERVAL 365 DAY THEN $val ELSE 0 END) AS cur,
SUM(CASE WHEN x.order_date <= CURDATE() - INTERVAL 365 DAY THEN $val ELSE 0 END) AS prev
FROM $from WHERE x.order_date > CURDATE() - INTERVAL 730 DAY AND x.customer <> ''
GROUP BY x.customer", []);
foreach ($mv as &$m) {
$m['cur'] = (float)$m['cur'];
$m['prev'] = (float)$m['prev'];
$m['delta'] = $m['cur'] - $m['prev'];
$m['pct'] = pctOf($m['cur'], $m['prev']);
}
unset($m);
usort($mv, function ($a, $b) {
return $b['delta'] <=> $a['delta'];
});
$up = array_values(array_filter(array_slice($mv, 0, 5), function ($m) {
return $m['delta'] > 0;
}));
$down = array_values(array_filter(array_slice(array_reverse($mv), 0, 5), function ($m) {
return $m['delta'] < 0;
}));
// ---------------- ORDINI IN ARRIVO: oggi, 7 giorni, 30 giorni (vs periodo precedente) ----------------
$fl = MagoSalesStats::fetchAllQ($pdo, "
SELECT
SUM(x.order_date = CURDATE()) AS n0,
SUM(CASE WHEN x.order_date = CURDATE() THEN x.taxable_amount ELSE 0 END) AS v0,
SUM(x.order_date = CURDATE() - INTERVAL 7 DAY) AS n0p,
SUM(CASE WHEN x.order_date = CURDATE() - INTERVAL 7 DAY THEN x.taxable_amount ELSE 0 END) AS v0p,
SUM(x.order_date > CURDATE() - INTERVAL 7 DAY) AS n7,
SUM(CASE WHEN x.order_date > CURDATE() - INTERVAL 7 DAY THEN x.taxable_amount ELSE 0 END) AS v7,
SUM(x.order_date <= CURDATE() - INTERVAL 7 DAY AND x.order_date > CURDATE() - INTERVAL 14 DAY) AS n7p,
SUM(CASE WHEN x.order_date <= CURDATE() - INTERVAL 7 DAY AND x.order_date > CURDATE() - INTERVAL 14 DAY THEN x.taxable_amount ELSE 0 END) AS v7p,
SUM(x.order_date > CURDATE() - INTERVAL 30 DAY) AS n30,
SUM(CASE WHEN x.order_date > CURDATE() - INTERVAL 30 DAY THEN x.taxable_amount ELSE 0 END) AS v30,
SUM(x.order_date <= CURDATE() - INTERVAL 30 DAY AND x.order_date > CURDATE() - INTERVAL 60 DAY) AS n30p,
SUM(CASE WHEN x.order_date <= CURDATE() - INTERVAL 30 DAY AND x.order_date > CURDATE() - INTERVAL 60 DAY THEN x.taxable_amount ELSE 0 END) AS v30p,
COUNT(DISTINCT CASE WHEN x.order_date > CURDATE() - INTERVAL 30 DAY THEN x.customer END) AS c30,
CURDATE() AS d0, CURDATE() - INTERVAL 6 DAY AS d7, CURDATE() - INTERVAL 29 DAY AS d30
FROM $from
WHERE x.status <> 'annullato' AND x.order_date > CURDATE() - INTERVAL 60 DAY AND x.order_date <= CURDATE()
", [])[0];
$flow = [
[
'key' => 'today',
'title' => 'Oggi',
'n' => (int)$fl['n0'],
'v' => (float)$fl['v0'],
'np' => (int)$fl['n0p'],
'vp' => (float)$fl['v0p'],
'vs' => 'stesso giorno della settimana scorsa',
'from' => (string)$fl['d0'],
'to' => (string)$fl['d0']
],
[
'key' => 'week',
'title' => 'Ultimi 7 giorni',
'n' => (int)$fl['n7'],
'v' => (float)$fl['v7'],
'np' => (int)$fl['n7p'],
'vp' => (float)$fl['v7p'],
'vs' => '7 giorni precedenti',
'from' => (string)$fl['d7'],
'to' => (string)$fl['d0']
],
[
'key' => 'month',
'title' => 'Ultimi 30 giorni',
'n' => (int)$fl['n30'],
'v' => (float)$fl['v30'],
'np' => (int)$fl['n30p'],
'vp' => (float)$fl['v30p'],
'vs' => '30 giorni precedenti',
'customers' => (int)$fl['c30'],
'from' => (string)$fl['d30'],
'to' => (string)$fl['d0']
],
];
// ---------------- GIUDIZIO COMPLESSIVO ----------------
$score = ['green' => 0, 'yellow' => 0, 'red' => 0, 'grey' => 0];
foreach ($T as $t) $score[$t['light']]++;
$valid = $score['green'] + $score['yellow'] + $score['red'];
$points = $valid ? ($score['green'] * 100 + $score['yellow'] * 50) / $valid : null;
$overall = $points === null ? 'grey' : ($points >= 70 ? 'green' : ($points >= 45 ? 'yellow' : 'red'));
MagoSalesStats::jsonOut([
'success' => true,
'today' => date('Y-m-d'),
'year' => $curY,
'inv_ok' => $invOk,
'tiles' => $T,
'flow' => $flow,
'score' => $score,
'points' => $points === null ? null : round($points),
'overall' => $overall,
'spark' => ['since' => $since13, 'ord' => $ordMonths, 'inv' => $invMonths],
'risk' => array_slice($atRisk, 0, 5),
'up' => $up,
'down' => $down,
'movers_on' => $invOk && $inv12 > 0 ? 'fatturato' : 'ordinato',
'can_deliveries' => $canDeliveries,
]);
} catch (Throwable $e) {
MagoSalesStats::jsonOut(['success' => false, 'message' => $e->getMessage()]);
}
}
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
<?php include('cssinclude.php'); ?>
<title>Sommario vendite - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- DataTables + export -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap5.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/buttons/2.4.2/css/buttons.bootstrap5.min.css">
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/dataTables.buttons.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.bootstrap5.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.html5.min.js"></script>
<!-- Select2 -->
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.full.min.js"></script>
<!-- Grafici -->
<script src="https://cdn.jsdelivr.net/npm/apexcharts@3.49.1"></script>
<style>
:root {
--c-ink: #1f2d3d;
--c-muted: #64748b;
--c-line: #e2e8f0;
}
body {
font-size: 1.02rem;
background: #f8fafc;
}
.card {
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.back-dashboard {
background-color: #b9ebc7 !important;
color: var(--c-ink) !important;
border-radius: 10px;
font-weight: 600;
padding: 10px 18px;
}
/* ---------- HEADER ---------- */
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.stats-title {
display: flex;
align-items: center;
gap: 14px;
}
.stats-title-icon {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #198754, #3bb77e);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
box-shadow: 0 6px 16px rgba(25, 135, 84, .25);
}
.sync-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 12px;
border-radius: 999px;
background: #eef6ff;
color: #0b3d91;
font-size: .85rem;
font-weight: 600;
}
.sync-pill .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #94a3b8;
}
.sync-pill.ok .dot {
background: #22c55e;
}
.sync-pill.err .dot {
background: #ef4444;
}
/* ---------- TAB PAGINE ---------- */
.stats-tabs {
display: flex;
gap: 6px;
border-bottom: 2px solid var(--c-line);
margin-bottom: 18px;
flex-wrap: wrap;
}
.stats-tabs a {
padding: 10px 18px;
border-radius: 12px 12px 0 0;
font-weight: 700;
color: var(--c-muted);
text-decoration: none;
background: #f1f5f9;
margin-bottom: -2px;
border: 2px solid transparent;
}
.stats-tabs a:hover {
color: var(--c-ink);
}
.stats-tabs a.active {
background: #fff;
color: #198754;
border-color: var(--c-line);
border-bottom-color: #fff;
}
/* ---------- FILTRI ---------- */
.filters-box {
background: #fff;
border: 1px solid var(--c-line);
border-radius: 16px;
padding: 16px;
box-shadow: 0 6px 18px rgba(15, 23, 42, .05);
}
.filters-box label.flabel {
font-weight: 700;
font-size: .8rem;
color: var(--c-muted);
text-transform: uppercase;
letter-spacing: .4px;
margin-bottom: 4px;
display: block;
}
.chip-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.chip {
border: 1.5px solid var(--c-line);
background: #fff;
color: var(--c-ink);
border-radius: 999px;
padding: 6px 14px;
font-weight: 700;
font-size: .92rem;
cursor: pointer;
transition: all .15s ease;
display: inline-flex;
align-items: center;
gap: 7px;
}
.chip .dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--yc, #94a3b8);
}
.chip.active {
background: var(--yc, #198754);
border-color: var(--yc, #198754);
color: #fff;
}
.chip.active .dot {
background: #fff;
}
.metric-switch {
display: inline-flex;
background: #f1f5f9;
border-radius: 12px;
padding: 4px;
gap: 4px;
}
.metric-switch button {
border: 0;
background: transparent;
padding: 7px 16px;
border-radius: 9px;
font-weight: 700;
color: var(--c-muted);
}
.metric-switch button.active {
background: #fff;
color: var(--c-ink);
box-shadow: 0 2px 6px rgba(15, 23, 42, .1);
}
.ytd-switch {
display: inline-flex;
align-items: center;
gap: 10px;
background: #f1f5f9;
border-radius: 12px;
padding: 7px 14px;
font-weight: 700;
color: var(--c-ink);
cursor: pointer;
user-select: none;
}
.ytd-switch .form-check-input {
width: 2.6em;
height: 1.35em;
margin: 0;
cursor: pointer;
}
.ytd-switch .form-check-input:checked {
background-color: #198754;
border-color: #198754;
}
.select2-container--bootstrap-5 .select2-selection {
border-radius: 10px;
min-height: 42px;
}
/* ---------- CARTE ANNO ---------- */
.year-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
}
.year-card {
background: color-mix(in srgb, var(--yc) 12%, #fff);
border: 1px solid color-mix(in srgb, var(--yc) 28%, #fff);
border-radius: 16px;
padding: 16px 18px;
}
.year-card .y {
font-size: 1.05rem;
font-weight: 800;
color: color-mix(in srgb, var(--yc) 75%, #1f2d3d);
display: flex;
justify-content: space-between;
align-items: center;
}
.year-card .y small {
font-weight: 600;
font-size: .78rem;
color: var(--c-muted);
}
.year-card .big {
font-size: 1.9rem;
font-weight: 800;
color: var(--c-ink);
font-variant-numeric: tabular-nums;
line-height: 1.15;
margin-top: 6px;
}
.year-card .stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
margin-top: 10px;
}
.year-card .stats div {
background: #fff;
border-radius: 10px;
padding: 6px 8px;
}
.year-card .stats b {
display: block;
font-size: 1rem;
color: var(--c-ink);
font-variant-numeric: tabular-nums;
}
.year-card .stats span {
font-size: .72rem;
color: var(--c-muted);
font-weight: 700;
text-transform: uppercase;
}
/* ---------- CRUSCOTTO ---------- */
.dash-head {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
font-weight: 800;
color: var(--c-ink);
margin-bottom: 10px;
}
.dash-head small {
font-weight: 600;
color: var(--c-muted);
font-size: .8rem;
}
.dash-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 14px;
}
@media (min-width: 1200px) {
.dash-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.dash-card {
background: color-mix(in srgb, var(--k) 12%, #fff);
border: 1px solid color-mix(in srgb, var(--k) 28%, #fff);
border-radius: 16px;
padding: 16px 18px;
position: relative;
}
.dash-card .lbl {
font-size: .78rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .5px;
color: color-mix(in srgb, var(--k) 70%, #1f2d3d);
padding-right: 40px;
}
.dash-card .ico {
position: absolute;
right: 14px;
top: 14px;
width: 36px;
height: 36px;
border-radius: 10px;
background: #fff;
color: var(--k);
display: flex;
align-items: center;
justify-content: center;
}
.dash-card .big {
font-size: 1.75rem;
font-weight: 800;
color: var(--c-ink);
line-height: 1.15;
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.dash-card .sub {
font-size: .84rem;
color: var(--c-muted);
margin-top: 3px;
line-height: 1.4;
}
.dash-card .sub b {
color: var(--c-ink);
}
.b2b-pill {
display: inline-block;
font-size: .72rem;
font-weight: 800;
border-radius: 999px;
padding: 2px 9px;
margin-left: 6px;
vertical-align: middle;
}
.b2b-pill.up {
background: #dcfce7;
color: #166534;
}
.b2b-pill.flat {
background: #fef3c7;
color: #92400e;
}
.b2b-pill.down {
background: #fee2e2;
color: #991b1b;
}
/* ---------- CLIENTI DA RICHIAMARE ---------- */
.dorm-sum {
font-size: .9rem;
color: var(--c-muted);
}
.dorm-sum b {
color: var(--c-ink);
}
.lvl {
display: inline-block;
font-size: .72rem;
font-weight: 800;
border-radius: 999px;
padding: 2px 9px;
white-space: nowrap;
}
.lvl.watch {
background: #fef9c3;
color: #854d0e;
}
.lvl.dormant {
background: #ffedd5;
color: #9a3412;
}
.lvl.lost {
background: #fee2e2;
color: #991b1b;
}
.ratio-bar {
display: inline-block;
width: 54px;
height: 6px;
border-radius: 99px;
background: #eef2f7;
vertical-align: middle;
margin-left: 6px;
overflow: hidden;
}
.ratio-bar i {
display: block;
height: 100%;
border-radius: 99px;
}
#dormTable tbody tr {
cursor: pointer;
}
.inv-toggle {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
background: none;
border: 0;
padding: 0;
cursor: pointer;
font-weight: 800;
color: var(--c-ink);
text-align: left;
}
.inv-toggle>span:first-child {
flex: 0 0 auto;
}
.inv-toggle .inv-chev {
margin-left: auto;
transition: transform .2s ease;
}
.inv-toggle[aria-expanded="true"] .inv-chev {
transform: rotate(180deg);
}
#invMonthTable tfoot th {
border-top: 2px solid var(--c-line);
font-weight: 800;
}
.doc-type {
display: inline-block;
font-size: .72rem;
font-weight: 800;
border-radius: 999px;
padding: 2px 8px;
white-space: nowrap;
}
.doc-type.t-inv {
background: #dcfce7;
color: #166534;
}
.doc-type.t-adv {
background: #e0f2fe;
color: #075985;
}
.doc-type.t-cn {
background: #fee2e2;
color: #991b1b;
}
.year-card .inv {
margin-top: 8px;
background: #fff;
border-radius: 10px;
padding: 6px 10px;
font-size: .85rem;
color: var(--c-muted);
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.year-card .inv b {
color: var(--c-ink);
font-size: 1rem;
font-variant-numeric: tabular-nums;
}
.year-card .deltas {
margin-top: 10px;
font-size: .88rem;
color: var(--c-muted);
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.delta-up {
color: #198754;
font-weight: 800;
}
.delta-down {
color: #dc3545;
font-weight: 800;
}
/* ---------- GRAFICI / TABELLA ---------- */
.chart-card {
background: #fff;
border: 1px solid var(--c-line);
border-radius: 16px;
padding: 16px;
height: 100%;
}
.chart-title {
font-weight: 800;
color: var(--c-ink);
font-size: 1rem;
}
.chart-hint {
font-size: .8rem;
color: var(--c-muted);
}
#custTable thead th {
background: #b9ebc7;
color: var(--c-ink);
white-space: nowrap;
}
#custTable tbody tr {
cursor: pointer;
}
.amount {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.trend {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 999px;
font-weight: 800;
font-size: .78rem;
white-space: nowrap;
}
.trend-new {
background: #e0f2fe;
color: #0369a1;
}
.trend-lost {
background: #fde8ea;
color: #b02a37;
}
.trend-up {
background: #dcfce7;
color: #15803d;
}
.trend-down {
background: #ffedd5;
color: #c2410c;
}
.trend-flat {
background: #f1f5f9;
color: #475569;
}
.warn-strip {
background: #fff8e6;
border: 1px solid #ffe2a8;
color: #8a5a00;
border-radius: 12px;
padding: 10px 14px;
font-size: .9rem;
}
.dataTables_wrapper .dt-buttons .btn {
border-radius: 10px;
font-weight: 600;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--c-muted);
}
.sc-link {
color: #198754;
margin-left: 6px;
opacity: .75;
text-decoration: none;
}
.sc-link:hover {
opacity: 1;
}
/* ================= SOMMARIO ================= */
.hero {
--hc: #64748b;
position: relative;
overflow: hidden;
border-radius: 22px;
padding: 26px 28px;
color: #fff;
background:
radial-gradient(900px 300px at 0% 0%, color-mix(in srgb, var(--hc) 55%, transparent), transparent 70%),
linear-gradient(135deg, #0f172a, #1e293b 60%, color-mix(in srgb, var(--hc) 35%, #1e293b));
box-shadow: 0 18px 40px rgba(15, 23, 42, .25);
display: grid;
grid-template-columns: auto 1fr auto;
gap: 28px;
align-items: center;
}
.hero.green {
--hc: #22c55e;
}
.hero.yellow {
--hc: #f59e0b;
}
.hero.red {
--hc: #ef4444;
}
@media (max-width: 991px) {
.hero {
grid-template-columns: 1fr;
text-align: left;
}
}
.lights {
background: #0b1120;
border: 2px solid #334155;
border-radius: 40px;
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 10px;
width: 66px;
align-items: center;
box-shadow: inset 0 0 12px rgba(0, 0, 0, .6);
}
@media (max-width: 991px) {
.lights {
flex-direction: row;
width: auto;
display: inline-flex;
}
}
.lamp {
width: 42px;
height: 42px;
border-radius: 50%;
background: #1f2937;
opacity: .35;
transition: all .3s ease;
}
.lamp.r {
background: #ef4444;
}
.lamp.y {
background: #f59e0b;
}
.lamp.g {
background: #22c55e;
}
.lamp.on {
opacity: 1;
box-shadow: 0 0 18px 4px currentColor, inset 0 -4px 8px rgba(0, 0, 0, .25);
}
.lamp.r.on {
color: rgba(239, 68, 68, .75);
}
.lamp.y.on {
color: rgba(245, 158, 11, .75);
}
.lamp.g.on {
color: rgba(34, 197, 94, .75);
}
.hero .kicker {
text-transform: uppercase;
letter-spacing: 1.5px;
font-size: .78rem;
font-weight: 800;
opacity: .75;
}
.hero h2 {
font-weight: 900;
font-size: 2.1rem;
margin: 4px 0 6px;
color: #fff;
line-height: 1.1;
}
.hero .lead2 {
font-size: 1.02rem;
opacity: .9;
max-width: 720px;
}
.hero .counts {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 14px;
}
.hero .cnt {
background: rgba(255, 255, 255, .08);
border: 1px solid rgba(255, 255, 255, .15);
border-radius: 999px;
padding: 5px 12px;
font-weight: 700;
font-size: .88rem;
display: inline-flex;
align-items: center;
gap: 7px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
flex: 0 0 auto;
}
.dot.green {
background: #22c55e;
}
.dot.yellow {
background: #f59e0b;
}
.dot.red {
background: #ef4444;
}
.dot.grey {
background: #94a3b8;
}
.ring {
width: 132px;
height: 132px;
border-radius: 50%;
display: grid;
place-items: center;
background: conic-gradient(var(--hc) calc(var(--p) * 1%), rgba(255, 255, 255, .12) 0);
position: relative;
}
.ring::after {
content: '';
position: absolute;
inset: 12px;
border-radius: 50%;
background: #172033;
}
.ring span {
position: relative;
z-index: 1;
text-align: center;
font-weight: 900;
font-size: 2rem;
line-height: 1;
}
.ring small {
display: block;
font-size: .7rem;
font-weight: 700;
opacity: .7;
letter-spacing: .5px;
margin-top: 4px;
}
.flow-head {
font-weight: 900;
font-size: 1.05rem;
color: var(--c-ink);
margin: 4px 0 10px;
}
.flow-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 14px;
}
.fl-card {
display: block;
text-decoration: none;
color: inherit;
background: linear-gradient(135deg, #eff6ff, #fff);
border: 1px solid #dbeafe;
border-radius: 16px;
padding: 14px 16px;
box-shadow: 0 6px 18px rgba(15, 23, 42, .05);
transition: transform .15s ease, box-shadow .15s ease;
}
.fl-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 26px rgba(15, 23, 42, .1);
color: inherit;
}
.fl-t {
font-size: .78rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .5px;
color: #1d4ed8;
}
.fl-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 10px;
margin-top: 6px;
}
.fl-n {
font-size: 2.1rem;
font-weight: 900;
line-height: 1;
color: var(--c-ink);
}
.fl-v {
font-size: 1.35rem;
font-weight: 800;
color: var(--c-ink);
line-height: 1.1;
}
.fl-l {
font-size: .8rem;
color: var(--c-muted);
margin-top: 4px;
}
.fl-d {
font-weight: 800;
margin-left: 4px;
white-space: nowrap;
}
.fl-d.up {
color: #16a34a;
}
.fl-d.down {
color: #dc2626;
}
.fl-d.flat {
color: #64748b;
}
.fl-vs {
font-size: .76rem;
color: var(--c-muted);
margin-top: 8px;
border-top: 1px dashed #dbeafe;
padding-top: 6px;
}
.goodbad {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 767px) {
.goodbad {
grid-template-columns: 1fr;
}
}
.gb-card {
border-radius: 16px;
padding: 16px 18px;
border: 1px solid;
}
.gb-card.good {
background: #f0fdf4;
border-color: #bbf7d0;
}
.gb-card.bad {
background: #fff7ed;
border-color: #fed7aa;
}
.gb-card h6 {
font-weight: 800;
margin-bottom: 10px;
}
.gb-card.good h6 {
color: #166534;
}
.gb-card.bad h6 {
color: #9a3412;
}
.gb-card ul {
list-style: none;
padding: 0;
margin: 0;
}
.gb-card li {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 6px 0;
border-bottom: 1px dashed rgba(15, 23, 42, .08);
font-size: .93rem;
}
.gb-card li:last-child {
border-bottom: 0;
}
.gb-card li .dot {
margin-top: 6px;
}
.gb-card li b {
color: var(--c-ink);
}
.sec-title {
font-weight: 900;
font-size: 1.05rem;
color: var(--c-ink);
margin: 26px 0 12px;
display: flex;
align-items: center;
gap: 10px;
}
.sec-title::after {
content: '';
flex: 1;
height: 2px;
background: var(--c-line);
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.tile {
--t: #94a3b8;
background: #fff;
border: 1px solid var(--c-line);
border-radius: 18px;
padding: 16px 18px 14px;
position: relative;
overflow: hidden;
box-shadow: 0 6px 18px rgba(15, 23, 42, .05);
display: flex;
flex-direction: column;
transition: transform .15s ease, box-shadow .15s ease;
}
.tile:hover {
transform: translateY(-2px);
box-shadow: 0 14px 30px rgba(15, 23, 42, .1);
}
.tile::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6px;
background: var(--t);
}
.tile.green {
--t: #22c55e;
background: linear-gradient(180deg, #f0fdf4, #fff 55%);
}
.tile.yellow {
--t: #f59e0b;
background: linear-gradient(180deg, #fffbeb, #fff 55%);
}
.tile.red {
--t: #ef4444;
background: linear-gradient(180deg, #fef2f2, #fff 55%);
}
.tile .top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.tile .ttl {
font-size: .78rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .5px;
color: #475569;
}
.badge-light {
font-size: .72rem;
font-weight: 800;
border-radius: 999px;
padding: 3px 10px;
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.badge-light.green {
background: #dcfce7;
color: #166534;
}
.badge-light.yellow {
background: #fef3c7;
color: #92400e;
}
.badge-light.red {
background: #fee2e2;
color: #991b1b;
}
.badge-light.grey {
background: #e2e8f0;
color: #475569;
}
.tile .val {
font-size: 2rem;
font-weight: 900;
color: var(--c-ink);
line-height: 1.1;
margin-top: 8px;
font-variant-numeric: tabular-nums;
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
}
.tile .delta {
font-size: .9rem;
font-weight: 800;
}
.tile .delta.up {
color: #16a34a;
}
.tile .delta.down {
color: #dc2626;
}
.tile .sub {
font-size: .82rem;
color: var(--c-muted);
margin-top: 2px;
}
.tile .verdict {
margin-top: 10px;
font-size: .9rem;
font-weight: 600;
color: color-mix(in srgb, var(--t) 60%, #1f2d3d);
}
.tile .spark {
margin: 6px -6px -4px;
min-height: 46px;
}
.tile .go {
margin-top: auto;
padding-top: 10px;
font-size: .82rem;
font-weight: 700;
text-decoration: none;
color: #64748b;
align-self: flex-end;
}
.tile .go:hover {
color: #198754;
}
.lists {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
}
.lcard {
background: #fff;
border: 1px solid var(--c-line);
border-radius: 18px;
padding: 16px 18px;
box-shadow: 0 6px 18px rgba(15, 23, 42, .05);
}
.lcard h6 {
font-weight: 800;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.lrow {
display: grid;
grid-template-columns: 1fr auto;
gap: 4px 10px;
padding: 8px 0;
border-bottom: 1px dashed var(--c-line);
align-items: center;
}
.lrow:last-child {
border-bottom: 0;
}
.lrow a {
font-weight: 700;
color: var(--c-ink);
text-decoration: none;
}
.lrow a:hover {
color: #198754;
}
.lrow .meta {
font-size: .78rem;
color: var(--c-muted);
grid-column: 1 / -1;
}
.lrow .amt {
font-weight: 800;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.bar {
height: 6px;
border-radius: 99px;
background: #eef2f7;
overflow: hidden;
grid-column: 1 / -1;
}
.bar i {
display: block;
height: 100%;
border-radius: 99px;
}
.lvl {
display: inline-block;
font-size: .7rem;
font-weight: 800;
border-radius: 999px;
padding: 1px 8px;
margin-left: 4px;
}
.lvl.dormant {
background: #ffedd5;
color: #9a3412;
}
.lvl.lost {
background: #fee2e2;
color: #991b1b;
}
.legend-strip {
font-size: .8rem;
color: var(--c-muted);
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: center;
margin-top: 18px;
}
</style>
</head>
<body>
<div class="wrapper toggled" id="appWrapper">
<?php include('include/navbar.php'); ?>
<?php include('include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<div class="card p-3">
<!-- HEADER -->
<div class="card-header bg-white border-0 pb-3">
<div class="stats-header">
<div class="stats-title">
<div class="stats-title-icon"><i class="fa-solid fa-gauge-high"></i></div>
<div>
<h5 class="mb-1" style="font-weight:800;">Statistiche Ordini Cliente</h5>
<span class="sync-pill" id="syncPill"><span class="dot"></span><span id="syncText">Dati Mago4</span></span>
</div>
</div>
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">↩️ Torna alla Dashboard</button>
</div>
</div>
<div class="card-body pt-0">
<div class="stats-tabs">
<a href="sales_summary.php" class="active"><i class="fa-solid fa-gauge-high me-1"></i> Sommario</a>
<a href="sales_orders_stats.php"><i class="fa-solid fa-list-check me-1"></i> Andamento ordini</a>
<a href="sales_orders_years.php"><i class="fa-solid fa-chart-column me-1"></i> Confronto anni</a>
<a href="sales_customer.php"><i class="fa-solid fa-address-card me-1"></i> Scheda cliente</a>
<?php if (userCan('sales.matrici.view')) : ?>
<a href="sales_matrici.php"><i class="fa-solid fa-shapes me-1"></i> Matrici</a>
<?php endif; ?>
<?php if ($canDeliveries) : ?>
<a href="sales_orders_deliveries.php"><i class="fa-solid fa-truck-fast me-1"></i> Consegne</a>
<?php endif; ?>
<button type="button" class="sh-page ms-auto align-self-center mb-1" data-help-open="page.summary"><i class="fa-regular fa-circle-question me-1"></i> Come si legge</button>
</div>
<div id="loading" class="empty-state"><i class="fa-solid fa-spinner fa-spin fa-2x mb-2"></i>
<div>Calcolo degli indicatori…</div>
</div>
<div id="content" style="display:none;">
<!-- GIUDIZIO COMPLESSIVO -->
<div class="hero mb-3" id="hero">
<div class="lights" aria-hidden="true">
<div class="lamp r"></div>
<div class="lamp y"></div>
<div class="lamp g"></div>
</div>
<div>
<div class="kicker" id="heroKicker">Situazione vendite</div>
<h2 id="heroTitle">—</h2>
<div class="lead2" id="heroLead"></div>
<div class="counts" id="heroCounts"></div>
</div>
<div class="ring" id="heroRing" style="--p:0;"><span id="heroPoints">—<small>PUNTEGGIO</small></span></div>
</div>
<!-- ORDINI IN ARRIVO -->
<div class="flow-head"><span data-help="sm.flow"><i class="fa-solid fa-inbox me-2"></i>Nuovi ordini</span></div>
<div class="flow-grid mb-3" id="flow"></div>
<div class="goodbad mb-2">
<div class="gb-card good">
<h6><i class="fa-solid fa-circle-check me-1"></i> Cosa va bene</h6>
<ul id="goodList"></ul>
</div>
<div class="gb-card bad">
<h6><i class="fa-solid fa-triangle-exclamation me-1"></i> Cosa non va / da tenere d'occhio</h6>
<ul id="badList"></ul>
</div>
</div>
<div id="groups"></div>
<div class="sec-title"><i class="fa-solid fa-users"></i> Clienti da guardare</div>
<div class="lists">
<div class="lcard">
<h6 data-help="sm.up"><i class="fa-solid fa-arrow-trend-up text-success"></i> Clienti che crescono</h6>
<div id="listUp"></div>
</div>
<div class="lcard">
<h6 data-help="sm.down"><i class="fa-solid fa-arrow-trend-down text-danger"></i> Clienti che calano</h6>
<div id="listDown"></div>
</div>
<div class="lcard">
<h6 data-help="sm.call"><i class="fa-solid fa-phone-volume" style="color:#c2410c;"></i> Da richiamare subito</h6>
<div id="listRisk"></div>
</div>
</div>
<div class="legend-strip">
<span><span class="dot green"></span> Bene</span>
<span><span class="dot yellow"></span> Così così: da tenere d'occhio</span>
<span><span class="dot red"></span> Da Migliorare: serve un intervento</span>
<span><span class="dot grey"></span> Dato non disponibile</span>
<span class="ms-auto">Clicca una tessera per i dettagli · ⓘ per la spiegazione</span>
</div>
</div>
</div>
</div>
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
<?php include('jsinclude.php'); ?>
<script>
// Navbar collassata all'apertura: si espande al passaggio del mouse (come dopo il click sulla freccia)
$(function() {
$('.sidebar-wrapper').hover(
function() {
$('.wrapper.toggled').addClass('sidebar-hovered');
},
function() {
$('.wrapper').removeClass('sidebar-hovered');
}
);
});
</script>
<script>
(function() {
const MONTHS = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'];
const LIGHT_LABEL = {
green: 'Bene',
yellow: 'Così così',
red: 'Da Migliorare',
grey: 'n.d.'
};
const LIGHT_ICON = {
green: 'fa-circle-check',
yellow: 'fa-circle-exclamation',
red: 'fa-circle-xmark',
grey: 'fa-circle-question'
};
const GROUPS = [{
k: 'Risultati',
icon: 'fa-trophy'
}, {
k: 'Futuro',
icon: 'fa-binoculars'
}, {
k: 'Clienti e servizio',
icon: 'fa-handshake'
}];
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
} [c]));
}
function fmtEurShort(n) {
n = +n || 0;
const a = Math.abs(n);
if (a >= 1e6) return (n / 1e6).toLocaleString('it-IT', {
maximumFractionDigits: 2
}) + ' M€';
if (a >= 1e3) return (n / 1e3).toLocaleString('it-IT', {
maximumFractionDigits: 1
}) + ' k€';
return n.toLocaleString('it-IT', {
maximumFractionDigits: 0
}) + ' €';
}
const pctTxt = p => (p >= 0 ? '+' : '') + Math.round(p).toLocaleString('it-IT') + '%';
const custLink = (code, name) => `<a href="sales_customer.php?c=${encodeURIComponent(code)}" title="Apri la scheda cliente">${esc(name || code)}</a>`;
function load() {
fetch('?action=filters').then(r => r.json()).then(d => {
const s = d.sync || {};
const so = s.sale_ord || {};
$('#syncPill').toggleClass('ok', so.last_status === 'ok').toggleClass('err', so.last_status === 'errore');
$('#syncText').text(so.last_run_at ? `Aggiornato il ${so.last_run_at} · ${(+s.total_orders || 0).toLocaleString('it-IT')} ordini in archivio` : 'Dati Mago4');
}).catch(() => {});
fetch('?action=data').then(r => r.json()).then(d => {
$('#loading').hide();
if (!d.success) {
$('#loading').show().html(`<div class="fw-bold">${esc(d.message || 'Errore nel caricamento')}</div>`);
return;
}
$('#content').show();
renderHero(d);
renderFlow(d);
renderGoodBad(d);
renderTiles(d);
renderLists(d);
}).catch(e => {
$('#loading').html('<div class="fw-bold">Errore nel caricamento</div>');
console.error(e);
});
}
/* ---------------- GIUDIZIO ---------------- */
function renderHero(d) {
const o = d.overall;
const titles = {
green: 'Le vendite vanno bene 👍',
yellow: 'Situazione da tenere d\'occhio',
red: 'Situazione critica: servono interventi',
grey: 'Dati insufficienti'
};
const leads = {
green: 'La maggior parte degli indicatori è verde. Qui sotto trovi i punti di forza e i pochi aspetti da presidiare.',
yellow: 'Ci sono segnali positivi ma anche aspetti da migliorare: guarda la colonna arancione qui sotto.',
red: 'Diversi indicatori sono in rosso. Parti dalla lista "Cosa non va" per decidere le priorità.',
grey: 'Sincronizza i dati da Mago per vedere il giudizio complessivo.'
};
const today = new Date().toLocaleDateString('it-IT', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
});
$('#hero').removeClass('green yellow red').addClass(o);
$('#heroKicker').text('Situazione vendite · ' + today);
$('#heroTitle').text(titles[o]);
$('#heroLead').text(leads[o]);
$('.lamp').removeClass('on');
$(`.lamp.${{green: 'g', yellow: 'y', red: 'r'}[o] || 'none'}`).addClass('on');
const sc = d.score;
$('#heroCounts').html(['green', 'yellow', 'red'].map(k => `<span class="cnt"><span class="dot ${k}"></span>${sc[k]} ${LIGHT_LABEL[k].toLowerCase()}</span>`).join('') +
(sc.grey ? `<span class="cnt"><span class="dot grey"></span>${sc.grey} non disponibili</span>` : ''));
$('#heroRing').css('--p', d.points || 0);
$('#heroPoints').html(`${d.points === null ? '—' : d.points}<small>SU 100</small>`);
}
/* ---------------- NUOVI ORDINI ---------------- */
function arrow(cur, prev) {
if (!prev && !cur) return '<span class="fl-d flat">= come prima</span>';
if (!prev) return '<span class="fl-d up">▲ nuovo</span>';
const p = (cur - prev) / Math.abs(prev) * 100;
const r = Math.round(p);
if (Math.abs(r) < 1) return '<span class="fl-d flat">= stabile</span>';
return `<span class="fl-d ${r > 0 ? 'up' : 'down'}">${r > 0 ? '▲' : '▼'} ${Math.abs(r)}%</span>`;
}
function renderFlow(d) {
const icons = {
today: 'fa-sun',
week: 'fa-calendar-week',
month: 'fa-calendar'
};
$('#flow').html((d.flow || []).map(f => `
<a class="fl-card" href="sales_orders_stats.php?from=${encodeURIComponent(f.from || '')}&to=${encodeURIComponent(f.to || '')}&st=aperto,evaso,bloccato" title="Apri questi ordini in Andamento ordini">
<div class="fl-top"><span class="fl-t"><i class="fa-solid ${icons[f.key]} me-1"></i>${esc(f.title)}</span></div>
<div class="fl-row">
<div>
<div class="fl-n">${f.n.toLocaleString('it-IT')}</div>
<div class="fl-l">ordini ${arrow(f.n, f.np)}</div>
</div>
<div class="text-end">
<div class="fl-v">${fmtEurShort(f.v)}</div>
<div class="fl-l">valore ${arrow(f.v, f.vp)}</div>
</div>
</div>
<div class="fl-vs">vs ${esc(f.vs)}: ${f.np.toLocaleString('it-IT')} ordini · ${fmtEurShort(f.vp)}${f.customers !== undefined ? ` · ${f.customers} clienti nel mese` : ''}</div>
</a>`).join(''));
}
function renderGoodBad(d) {
const ok = d.tiles.filter(t => t.light === 'green' && t.verdict);
const ko = d.tiles.filter(t => (t.light === 'red' || t.light === 'yellow') && t.verdict)
.sort((a, b) => (a.light === 'red' ? 0 : 1) - (b.light === 'red' ? 0 : 1));
const li = t => `<li><span class="dot ${t.light}"></span><div><b>${esc(t.title)}: ${esc(t.value)}</b><br>${esc(t.verdict)}</div></li>`;
$('#goodList').html(ok.length ? ok.map(li).join('') : '<li class="text-muted">Nessun indicatore in verde al momento.</li>');
$('#badList').html(ko.length ? ko.map(li).join('') : '<li class="text-muted">Niente da segnalare 🎉</li>');
}
/* ---------------- TESSERE ---------------- */
function renderTiles(d) {
const html = GROUPS.map(g => {
const tiles = d.tiles.filter(t => t.group === g.k);
if (!tiles.length) return '';
return `<div class="sec-title"><i class="fa-solid ${g.icon}"></i> ${esc(g.k)}</div>
<div class="tiles">${tiles.map(tileHtml).join('')}</div>`;
}).join('');
$('#groups').html(html);
d.tiles.forEach(t => {
if (t.spark) drawSpark(t, d);
});
}
function tileHtml(t) {
const delta = (t.delta !== undefined && t.delta !== null) ? `<span class="delta ${t.delta >= 0 ? 'up' : 'down'}">${t.delta >= 0 ? '▲' : '▼'} ${pctTxt(t.delta)}</span>` : '';
return `
<div class="tile ${t.light}" data-link="${esc(t.link || '')}" style="cursor:${t.link ? 'pointer' : 'default'};">
<div class="top">
<span class="ttl" data-help="sm.${esc(t.key)}">${esc(t.title)}</span>
<span class="badge-light ${t.light}"><i class="fa-solid ${LIGHT_ICON[t.light]}"></i>${LIGHT_LABEL[t.light]}</span>
</div>
<div class="val">${esc(t.value)} ${delta}</div>
<div class="sub">${esc(t.sub || '')}</div>
${t.spark ? `<div class="spark" id="sp_${esc(t.key)}"></div>` : ''}
${t.verdict ? `<div class="verdict">${esc(t.verdict)}</div>` : ''}
${t.link ? `<a class="go" href="${esc(t.link)}">Dettagli <i class="fa-solid fa-arrow-right"></i></a>` : ''}
</div>`;
}
function monthKeys(since) {
const keys = [];
const s = new Date(since + 'T00:00:00');
for (let i = 0; i < 13; i++) {
const dd = new Date(s.getFullYear(), s.getMonth() + i, 1);
keys.push(dd.getFullYear() + '-' + String(dd.getMonth() + 1).padStart(2, '0'));
}
return keys;
}
function drawSpark(t, d) {
const el = document.getElementById('sp_' + t.key);
if (!el) return;
const keys = monthKeys(d.spark.since);
const map = rows => {
const m = {};
(rows || []).forEach(r => m[r.ym] = +r.v);
return keys.map(k => m[k] || 0);
};
let data, fmt;
if (t.spark === 'b2b') {
const o = map(d.spark.ord),
f = map(d.spark.inv);
data = keys.map((k, i) => {
if (i < 2) return null;
const ff = f[i] + f[i - 1] + f[i - 2];
return ff > 0 ? Math.round((o[i] + o[i - 1] + o[i - 2]) / ff * 100) / 100 : null;
});
fmt = v => v === null || v === undefined ? '—' : v.toLocaleString('it-IT', {
minimumFractionDigits: 2
});
} else {
data = map(d.spark[t.spark]);
fmt = v => fmtEurShort(v);
}
const color = {
green: '#16a34a',
yellow: '#d97706',
red: '#dc2626',
grey: '#64748b'
} [t.light];
new ApexCharts(el, {
chart: {
type: 'area',
height: 50,
sparkline: {
enabled: true
},
animations: {
speed: 500
}
},
series: [{
name: t.title,
data: data
}],
stroke: {
width: 2.5,
curve: 'smooth'
},
fill: {
type: 'gradient',
gradient: {
opacityFrom: .35,
opacityTo: 0
}
},
colors: [color],
xaxis: {
categories: keys.map(k => MONTHS[+k.substring(5) - 1] + ' ' + k.substring(2, 4))
},
tooltip: {
y: {
formatter: fmt
},
x: {
show: true
}
},
annotations: t.spark === 'b2b' ? {
yaxis: [{
y: 1,
borderColor: '#94a3b8',
strokeDashArray: 3
}]
} : {}
}).render();
}
/* ---------------- LISTE CLIENTI ---------------- */
function renderLists(d) {
const max = Math.max(1, ...d.up.map(m => m.delta), ...d.down.map(m => -m.delta));
const mv = (m, up) => `
<div class="lrow">
${custLink(m.code, m.name)}
<span class="amt ${up ? 'text-success' : 'text-danger'}">${up ? '+' : ''}${fmtEurShort(m.delta)}</span>
<div class="bar"><i style="width:${Math.abs(m.delta) / max * 100}%;background:${up ? '#22c55e' : '#ef4444'};"></i></div>
<div class="meta">${fmtEurShort(m.prev)} → ${fmtEurShort(m.cur)}${m.pct !== null ? ' · ' + pctTxt(m.pct) : ' · cliente nuovo'} (${esc(d.movers_on)} 12 mesi vs 12 precedenti)</div>
</div>`;
$('#listUp').html(d.up.length ? d.up.map(m => mv(m, true)).join('') : '<div class="text-muted small">Nessun cliente in crescita.</div>');
$('#listDown').html(d.down.length ? d.down.map(m => mv(m, false)).join('') : '<div class="text-muted small">Nessun cliente in calo 🎉</div>');
$('#listRisk').html(d.risk.length ? d.risk.map(c => `
<div class="lrow">
<span>${custLink(c.code, c.name)}<span class="lvl ${c.level}">${c.level === 'lost' ? 'Prob. perso' : 'Dormiente'}</span></span>
<span class="amt">${fmtEurShort(c.value12)}</span>
<div class="meta">ultimo ordine ${c.days_since} gg fa · di solito ogni ~${c.rhythm} gg · agente ${esc(c.salesperson || '—')}</div>
</div>`).join('') + '<div class="text-end mt-2"><a class="small fw-bold" href="sales_orders_years.php">Lista completa <i class="fa-solid fa-arrow-right"></i></a></div>' :
'<div class="text-muted small">Nessun cliente abituale si è fermato 🎉</div>');
}
/* ---------------- EVENTI ---------------- */
$(function() {
$('#groups').on('click', '.tile', function(e) {
if (e.target.closest('a, .sh-btn')) return;
const l = $(this).data('link');
if (l) location.href = l;
});
load();
});
})();
</script>
<script src="sales_help.js?v=3"></script>
</body>
</html>