2207 lines
77 KiB
PHP
2207 lines
77 KiB
PHP
<?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
|
||
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'
|
||
],
|
||
[
|
||
'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'
|
||
],
|
||
[
|
||
'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']
|
||
],
|
||
];
|
||
|
||
// ---------------- 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 ($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 => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
} [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" title="Apri 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>
|