Files
zibo-dashboard/public/userarea/sales_orders_years.php
T
2026-10-15 12:03:40 +02:00

2393 lines
100 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;
}
/* ============================================================
* AJAX
* ============================================================ */
$action = $_GET['action'] ?? '';
if ($action === 'filters') {
MagoSalesStats::jsonOut(MagoSalesStats::filterOptions($pdo));
}
// Elenco fatture / note di credito degli anni scelti (tabella a scomparsa sotto i grafici del fatturato)
if ($action === 'invdocs') {
try {
[$where, $params] = MagoSalesStats::buildWhere(['from' => '', 'to' => '']);
$iWhere = preg_replace('/\bx\./', 'i.', $where);
$iYtd = !empty($_GET['ytd']) ? " AND DATE_FORMAT(i.document_date, '%m-%d') <= DATE_FORMAT(CURDATE(), '%m-%d')" : '';
$years = array_values(array_unique(array_map('intval', MagoSalesStats::arrParam('years'))));
$years = array_filter($years, function ($y) {
return $y >= 2000 && $y <= 2100;
});
if (!$years) MagoSalesStats::jsonOut(['success' => true, 'docs' => []]);
$ph = [];
foreach (array_values($years) as $k => $y) {
$ph[] = ':dy' . $k;
$params['dy' . $k] = $y;
}
$docs = MagoSalesStats::fetchAllQ($pdo, "
SELECT i.document_date, i.doc_no, i.document_type, i.is_credit_note,
i.customer, i.company_name, i.salesperson, i.area, i.revenue, i.no_amount
FROM " . MagoSalesStats::invoiceFrom() . "
WHERE $iWhere AND YEAR(i.document_date) IN (" . implode(',', $ph) . ") $iYtd
ORDER BY i.document_date DESC, i.doc_no DESC
LIMIT 20000
", $params);
MagoSalesStats::jsonOut(['success' => true, 'docs' => $docs, 'types' => MagoSalesStats::INVOICE_TYPE_LABELS]);
} catch (Throwable $e) {
MagoSalesStats::jsonOut(['success' => false, 'message' => $e->getMessage()]);
}
}
if ($action === 'data') {
try {
$from = MagoSalesStats::baseFrom();
// Filtri cliente / agente / area (le date qui non si usano: si ragiona per anni)
[$where, $params] = MagoSalesStats::buildWhere(['from' => '', 'to' => '']);
// "A pari data": ogni anno considerato solo fino al giorno/mese di oggi
$ytd = !empty($_GET['ytd']);
$ytdCond = $ytd ? " AND DATE_FORMAT(x.order_date, '%m-%d') <= DATE_FORMAT(CURDATE(), '%m-%d')" : '';
// Metrica: numero ordini o valore (imponibile); gli annullati sono sempre esclusi
$metric = (($_GET['metric'] ?? '') === 'value') ? 'value' : 'count';
$m = $metric === 'value'
? "CASE WHEN x.status <> 'annullato' THEN x.taxable_amount ELSE 0 END"
: "CASE WHEN x.status <> 'annullato' THEN 1 ELSE 0 END";
// --- Riepilogo per TUTTI gli anni (serve anche l'anno prima del primo selezionato per la variazione) ---
$summary = MagoSalesStats::fetchAllQ($pdo, "
SELECT YEAR(x.order_date) AS y,
SUM(x.status <> 'annullato') AS orders,
COALESCE(SUM(CASE WHEN x.status <> 'annullato' THEN x.taxable_amount END), 0) AS value,
COUNT(DISTINCT CASE WHEN x.status <> 'annullato' THEN x.customer END) AS customers,
SUM(x.status = 'annullato') AS cancelled,
SUM(x.summary_tb_modified IS NULL) AS missing_amounts
FROM $from
WHERE $where AND x.order_date IS NOT NULL $ytdCond
GROUP BY y
ORDER BY y
", $params);
$available = array_map(function ($r) {
return (int)$r['y'];
}, $summary);
// Anni scelti (validati); default: gli ultimi 3 disponibili
$years = array_values(array_intersect(array_map('intval', MagoSalesStats::arrParam('years')), $available));
if (!$years) $years = array_slice($available, -3);
sort($years);
$monthly = [];
$customers = [];
$yearIn = '1=0';
$yp = $params;
if ($years) {
$ph = [];
$yp = $params;
foreach ($years as $i => $y) {
$ph[] = ':yy' . $i;
$yp['yy' . $i] = $y;
}
$yearIn = 'YEAR(x.order_date) IN (' . implode(',', $ph) . ')';
// --- Mensile per anno ---
$monthly = MagoSalesStats::fetchAllQ($pdo, "
SELECT YEAR(x.order_date) AS y, MONTH(x.order_date) AS m, SUM($m) AS v
FROM $from
WHERE $where AND $yearIn $ytdCond
GROUP BY y, m
ORDER BY y, m
", $yp);
// --- Clienti per anno ---
$rows = MagoSalesStats::fetchAllQ($pdo, "
SELECT x.customer AS code, x.company_name AS name, YEAR(x.order_date) AS y, SUM($m) AS v
FROM $from
WHERE $where AND $yearIn $ytdCond AND x.customer <> ''
GROUP BY x.customer, x.company_name, y
", $yp);
foreach ($rows as $r) {
$k = $r['code'];
if (!isset($customers[$k])) {
$customers[$k] = ['code' => $r['code'], 'name' => $r['name'], 'v' => []];
}
$customers[$k]['v'][(int)$r['y']] = (float)$r['v'];
}
$customers = array_values($customers);
}
// --- FATTURATO per data documento (fatture + acconti - note di credito) ---
$invoiced = ['available' => false, 'summary' => [], 'monthly' => []];
$iFrom = MagoSalesStats::invoiceFrom();
$iWhere = preg_replace('/\bx\./', 'i.', $where); // stessi filtri cliente/agente/area
try {
$iYtd = $ytd ? " AND DATE_FORMAT(i.document_date, '%m-%d') <= DATE_FORMAT(CURDATE(), '%m-%d')" : '';
$invoiced['summary'] = MagoSalesStats::fetchAllQ($pdo, "
SELECT YEAR(i.document_date) AS y,
COALESCE(SUM(i.revenue), 0) AS value,
SUM(i.is_credit_note = 0) AS docs,
SUM(i.is_credit_note = 1) AS credit_notes,
COALESCE(SUM(CASE WHEN i.is_credit_note = 1 THEN -i.revenue END), 0) AS credit_value,
SUM(i.no_amount) AS missing_amounts
FROM $iFrom
WHERE $iWhere AND i.document_date IS NOT NULL $iYtd
GROUP BY y ORDER BY y
", $params);
if ($years) {
$invoiced['monthly'] = MagoSalesStats::fetchAllQ($pdo, "
SELECT YEAR(i.document_date) AS y, MONTH(i.document_date) AS m, SUM(i.revenue) AS v
FROM $iFrom
WHERE $iWhere AND " . str_replace('x.order_date', 'i.document_date', $yearIn) . " $iYtd
GROUP BY y, m ORDER BY y, m
", $yp);
}
$invoiced['available'] = (bool)$pdo->query("SELECT 1 FROM mago_sale_doc LIMIT 1")->fetchColumn();
} catch (Throwable $e) {
$invoiced['error'] = 'Fatturato non disponibile: eseguire la migrazione e la sincronizzazione';
}
// ===== CRUSCOTTO DIREZIONE: situazione ad oggi (rispetta solo i filtri cliente / agente / area) =====
$curY = (int)date('Y');
$val = "CASE WHEN x.status <> 'annullato' THEN x.taxable_amount ELSE 0 END";
$dash = [];
// portafoglio da consegnare = valore residuo delle righe aperte
$rWhere = preg_replace('/\bx\./', 'r.', $where);
$dash['backlog'] = MagoSalesStats::fetchAllQ($pdo, "
SELECT COALESCE(SUM(r.residual_value), 0) AS v,
COUNT(*) AS n_lines,
COUNT(DISTINCT r.sale_ord_id) AS n_orders,
COALESCE(SUM(CASE WHEN r.due_date < CURDATE() THEN r.residual_value END), 0) AS late_v
FROM " . MagoSalesStats::openLinesFrom() . "
WHERE $rWhere
", $params)[0];
// ordinato: ultimi 90 giorni, mesi (24) e anni per la previsione
$dash['ordered_90'] = (float)MagoSalesStats::fetchAllQ($pdo, "
SELECT COALESCE(SUM($val), 0) AS v FROM $from WHERE $where AND x.order_date > CURDATE() - INTERVAL 90 DAY
", $params)[0]['v'];
$since24 = date('Y-m-01', strtotime('first day of -23 months'));
$dash['since'] = $since24;
$dash['ordered_months'] = MagoSalesStats::fetchAllQ($pdo, "
SELECT DATE_FORMAT(x.order_date, '%Y-%m') AS ym, SUM($val) AS v
FROM $from WHERE $where AND x.order_date >= '$since24'
GROUP BY ym ORDER BY ym
", $params);
$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 $where AND x.order_date >= '" . ($curY - 3) . "-01-01'
GROUP BY y
", $params);
$dash['ordered_forecast'] = MagoSalesStats::yearForecast($ordYears, $curY);
// fatturato (se i documenti sono sincronizzati)
$dash['invoiced_90'] = null;
$dash['invoiced_months'] = [];
$dash['invoiced_forecast'] = null;
if (!empty($invoiced['available'])) {
$dash['invoiced_90'] = (float)MagoSalesStats::fetchAllQ($pdo, "
SELECT COALESCE(SUM(i.revenue), 0) AS v FROM $iFrom WHERE $iWhere AND i.document_date > CURDATE() - INTERVAL 90 DAY
", $params)[0]['v'];
$dash['invoiced_months'] = MagoSalesStats::fetchAllQ($pdo, "
SELECT DATE_FORMAT(i.document_date, '%Y-%m') AS ym, SUM(i.revenue) AS v
FROM $iFrom WHERE $iWhere AND i.document_date >= '$since24'
GROUP BY ym ORDER BY ym
", $params);
$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 $iWhere AND i.document_date >= '" . ($curY - 3) . "-01-01'
GROUP BY y
", $params);
$dash['invoiced_forecast'] = MagoSalesStats::yearForecast($invYears, $curY);
}
// ===== CLIENTI DA RICHIAMARE =====
$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 $where AND x.status <> 'annullato' AND x.customer <> ''
AND x.order_date >= CURDATE() - INTERVAL " . (int)MagoSalesStats::DORMANT_WINDOW_YEARS . " YEAR
", $params);
$dormant = MagoSalesStats::dormantCustomers($dormRows);
MagoSalesStats::jsonOut([
'success' => true,
'dash' => $dash,
'dormant' => $dormant,
'dormant_cfg' => [
'years' => MagoSalesStats::DORMANT_WINDOW_YEARS,
'min_orders' => MagoSalesStats::DORMANT_MIN_ORDERS,
'watch' => MagoSalesStats::DORMANT_WATCH,
'sleep' => MagoSalesStats::DORMANT_SLEEP,
'lost' => MagoSalesStats::DORMANT_LOST,
],
'invoiced' => $invoiced,
'metric' => $metric,
'ytd' => $ytd,
'today' => date('Y-m-d'),
'available' => $available,
'years' => $years,
'summary' => $summary,
'monthly' => $monthly,
'customers' => $customers,
]);
} 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>Confronto anni - <?= 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;
}
</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-chart-column"></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_orders_stats.php"><i class="fa-solid fa-list-check me-1"></i> Andamento ordini</a>
<a href="sales_orders_years.php" class="active"><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.deliveries.view')) : ?>
<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.years"><i class="fa-regular fa-circle-question me-1"></i> Come si legge</button>
</div>
<!-- FILTRI -->
<div class="filters-box mb-4">
<div class="row g-3 align-items-end">
<div class="col-12 col-xl-6">
<label class="flabel">Anni da confrontare</label>
<div class="chip-row" id="yearChips"></div>
</div>
<div class="col-12 col-xl-6 d-flex gap-2 flex-wrap justify-content-xl-end">
<label class="ytd-switch" for="ytdToggle" title="Ogni anno viene considerato solo fino al giorno di oggi, per confronti equi con l'anno in corso">
<input class="form-check-input" type="checkbox" role="switch" id="ytdToggle" checked>
<span id="ytdLabel" data-help="yr.ytd">A pari data</span>
</label>
<div class="metric-switch" id="metricSwitch">
<button type="button" data-metric="value" class="active">€ Valore</button>
<button type="button" data-metric="count"># N° ordini</button>
</div>
</div>
</div>
<div class="row g-2 align-items-end mt-1">
<div class="col-12 col-md-5">
<label class="flabel" for="fCustomers">Clienti</label>
<select id="fCustomers" multiple style="width:100%;"></select>
</div>
<div class="col-6 col-md-3">
<label class="flabel" for="fSalespeople">Agente</label>
<select id="fSalespeople" multiple style="width:100%;"></select>
</div>
<div class="col-6 col-md-3">
<label class="flabel" for="fAreas">Area</label>
<select id="fAreas" multiple style="width:100%;"></select>
</div>
<div class="col-12 col-md-1 d-grid">
<button type="button" class="btn btn-outline-secondary" id="btnReset" title="Azzera filtri" style="border-radius:12px;min-height:42px;">
<i class="fa-solid fa-eraser"></i>
</button>
</div>
</div>
</div>
<div id="emptyState" class="empty-state" style="display:none;">
<div style="font-size:3rem;">📊</div>
<h5 class="mt-2" style="color:var(--c-ink);">Nessun dato da confrontare</h5>
<p>Sincronizza gli ordini da Mago dalla pagina <a href="sales_orders_stats.php">Andamento ordini</a>.</p>
</div>
<div id="content">
<div class="warn-strip mb-3" id="missingAmounts" style="display:none;"></div>
<!-- RIEPILOGO ANNI -->
<!-- CRUSCOTTO DIREZIONE: situazione ad oggi -->
<div class="dash-head">
<span data-help="yr.dash"><i class="fa-solid fa-gauge-high me-2"></i>Situazione ad oggi</span>
<small>non dipende dagli anni scelti · segue i filtri cliente, agente e area</small>
</div>
<div class="dash-grid mb-3" id="dashCards"></div>
<div class="chart-card mb-4">
<div class="chart-title" data-help="yr.b2bchart">Ordinato vs fatturato · ultimi 24 mesi</div>
<div class="chart-hint">Barre: valori del mese · linea: book-to-bill su 3 mesi (ordinato ÷ fatturato: sopra 1 il portafoglio cresce, sotto 1 si consuma)</div>
<div id="chB2B" style="min-height:320px;"></div>
</div>
<div class="dash-head"><span data-help="yr.cards"><i class="fa-solid fa-layer-group me-2"></i>Anni a confronto</span></div>
<div class="year-grid mb-4" id="yearCards"></div>
<!-- GRAFICI -->
<div class="row g-3 mb-3">
<div class="col-xl-6">
<div class="chart-card">
<div class="chart-title" id="ttlMonthly" data-help="yr.monthly">Andamento mensile</div>
<div class="chart-hint">Stagionalità: un colore per anno</div>
<div id="chMonthly" style="min-height:340px;"></div>
</div>
</div>
<div class="col-xl-6">
<div class="chart-card">
<div class="chart-title" id="ttlCumul" data-help="yr.cumul">Progressivo dell'anno</div>
<div class="chart-hint">Somma da gennaio: dice se si è avanti o indietro rispetto agli anni scorsi</div>
<div id="chCumul" style="min-height:340px;"></div>
</div>
</div>
</div>
<!-- FATTURATO (per data fattura) -->
<div class="row g-3 mb-3" id="invRow">
<div class="col-xl-6">
<div class="chart-card">
<div class="chart-title" data-help="yr.invMonthly">Fatturato per mese</div>
<div class="chart-hint">Imponibile fatture + acconti − note di credito, per data fattura</div>
<div id="chInvMonthly" style="min-height:340px;"></div>
</div>
</div>
<div class="col-xl-6">
<div class="chart-card">
<div class="chart-title" data-help="yr.invCumul">Fatturato progressivo dell'anno</div>
<div class="chart-hint">Somma da gennaio del fatturato, anno su anno</div>
<div id="chInvCumul" style="min-height:340px;"></div>
</div>
</div>
</div>
<div class="warn-strip mb-3" id="invMissing" style="display:none;"></div>
<div class="chart-card mb-3" id="invTableCard">
<div class="inv-toggle" id="invToggle" role="button" tabindex="0" aria-expanded="false">
<span data-help="yr.invTable"><i class="fa-solid fa-table me-2"></i>Tabella dati fatturato</span>
<span class="small text-muted d-none d-md-inline">per mese e documenti · esportabile in Excel</span>
<i class="fa-solid fa-chevron-down inv-chev"></i>
</div>
<div id="invTableBody" style="display:none;">
<div class="metric-switch my-3" id="invTabs">
<button type="button" data-tab="months" class="active"><i class="fa-regular fa-calendar me-1"></i> Per mese</button>
<button type="button" data-tab="docs"><i class="fa-regular fa-file-lines me-1"></i> Documenti</button>
</div>
<div id="invPaneMonths">
<div class="table-responsive">
<table id="invMonthTable" class="table table-sm table-striped align-middle w-100"></table>
</div>
</div>
<div id="invPaneDocs" style="display:none;">
<div class="table-responsive">
<table id="invDocTable" class="table table-sm table-striped align-middle w-100"></table>
</div>
</div>
</div>
</div>
<!-- CLIENTI DA RICHIAMARE -->
<div class="chart-card mb-3" id="dormCard">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-2">
<div>
<div class="chart-title" data-help="yr.dormant"><i class="fa-solid fa-phone-volume me-2" style="color:#c2410c;"></i>Clienti da richiamare</div>
<div class="chart-hint" id="dormHint">&nbsp;</div>
</div>
<div class="chip-row" id="dormChips">
<button type="button" class="chip active" data-level="" style="--yc:#198754;">Tutti <span class="badge bg-light text-dark" id="dCntAll">0</span></button>
<button type="button" class="chip" data-level="watch" style="--yc:#ca8a04;">Attenzione <span class="badge bg-light text-dark" id="dCntWatch">0</span></button>
<button type="button" class="chip" data-level="dormant" style="--yc:#c2410c;">Dormienti <span class="badge bg-light text-dark" id="dCntDormant">0</span></button>
<button type="button" class="chip" data-level="lost" style="--yc:#b02a37;">Probabilmente persi <span class="badge bg-light text-dark" id="dCntLost">0</span></button>
</div>
</div>
<div class="dorm-sum mb-2" id="dormSum"></div>
<div class="table-responsive">
<table id="dormTable" class="table table-striped align-middle w-100"></table>
</div>
</div>
<!-- CLIENTI -->
<div class="chart-card">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
<div>
<div class="chart-title" id="ttlCust" data-help="yr.custcmp">Clienti a confronto</div>
<div class="chart-hint" id="custHint">Clicca un cliente per filtrare tutta la pagina</div>
</div>
<div class="chip-row" id="trendChips">
<button type="button" class="chip active" data-trend="" style="--yc:#198754;">Tutti <span class="badge bg-light text-dark" id="cntAll">0</span></button>
<button type="button" class="chip" data-trend="new" style="--yc:#0369a1;">Nuovi <span class="badge bg-light text-dark" id="cntNew">0</span></button>
<button type="button" class="chip" data-trend="lost" style="--yc:#b02a37;">Persi <span class="badge bg-light text-dark" id="cntLost">0</span></button>
<button type="button" class="chip" data-trend="up" style="--yc:#15803d;">In crescita <span class="badge bg-light text-dark" id="cntUp">0</span></button>
<button type="button" class="chip" data-trend="down" style="--yc:#c2410c;">In calo <span class="badge bg-light text-dark" id="cntDown">0</span></button>
</div>
</div>
<div class="table-responsive">
<table id="custTable" class="table table-striped align-middle w-100"></table>
</div>
</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 PALETTE = ['#198754', '#0d6efd', '#fd7e14', '#6f42c1', '#0ea5e9', '#d63384', '#64748b', '#a16207'];
const STORE_KEY = 'zibo_sales_years_filters_v1';
const CUR_Y = new Date().getFullYear();
const charts = {};
let table = null;
let metric = 'value';
let selectedYears = [];
let trendFilter = '';
let lastData = null;
let loadTimer = null;
let reqSeq = 0;
/* ---------------- UTILS ---------------- */
const yearColor = y => PALETTE[Math.max(0, CUR_Y - y) % PALETTE.length];
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
} [c]));
}
const fmtNum = n => Number(n || 0).toLocaleString('it-IT');
const fmtEur = (n, dec) => Number(n || 0).toLocaleString('it-IT', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: dec || 0,
maximumFractionDigits: dec || 0
});
function fmtEurShort(n) {
const v = Number(n || 0),
a = Math.abs(v);
if (a >= 1e6) return (v / 1e6).toLocaleString('it-IT', {
maximumFractionDigits: 2
}) + ' M€';
if (a >= 1e4) return (v / 1e3).toLocaleString('it-IT', {
maximumFractionDigits: 1
}) + ' k€';
return fmtEur(v);
}
const fmtMetric = v => v === null || v === undefined ? '—' : (metric === 'value' ? fmtEur(v) : fmtNum(Math.round(v)));
const fmtAxis = v => metric === 'value' ? fmtEurShort(v) : fmtNum(Math.round(v));
function pct(cur, prev) {
if (!prev) return null;
return (cur - prev) * 100 / prev;
}
function deltaSpan(p, label) {
if (p === null) return '';
const r = Math.round(p);
return `<span><span class="${r >= 0 ? 'delta-up' : 'delta-down'}">${r >= 0 ? '▲' : '▼'} ${Math.abs(r)}%</span> ${label}</span>`;
}
function todayLabel() {
const d = new Date();
return `${d.getDate()} ${MONTHS[d.getMonth()]}`;
}
/* ---------------- STATO FILTRI ---------------- */
function save() {
try {
localStorage.setItem(STORE_KEY, JSON.stringify({
years: selectedYears,
metric: metric,
ytd: $('#ytdToggle').is(':checked'),
customers: $('#fCustomers').val() || [],
salespeople: $('#fSalespeople').val() || [],
areas: $('#fAreas').val() || []
}));
} catch (e) {}
}
function restore() {
try {
return JSON.parse(localStorage.getItem(STORE_KEY) || 'null');
} catch (e) {
return null;
}
}
function scheduleLoad(delay) {
clearTimeout(loadTimer);
loadTimer = setTimeout(loadData, delay === undefined ? 300 : delay);
}
function renderYearChips(available) {
const years = available.slice().sort((a, b) => b - a);
$('#yearChips').html(years.map(y =>
`<button type="button" class="chip${selectedYears.includes(y) ? ' active' : ''}" data-year="${y}" style="--yc:${yearColor(y)};"><span class="dot"></span>${y}</button>`
).join(''));
}
function renderSyncInfo(s) {
const $p = $('#syncPill').removeClass('ok err');
const o = s && s.sale_ord;
if (!s || s.error || !o || !o.last_run_at) {
$('#syncText').text(s && s.error ? s.error : 'Mai sincronizzato');
if (s && s.error) $p.addClass('err');
return;
}
$p.addClass(o.last_status === 'ok' ? 'ok' : 'err');
$('#syncText').text(`Aggiornato il ${o.last_run_at} · ${fmtNum(s.total_orders)} ordini in archivio`);
}
/* ---------------- CARICAMENTO ---------------- */
function loadFilters() {
return fetch('?action=filters').then(r => r.json()).then(d => {
renderSyncInfo(d.sync);
if (!d.success) return d;
const fill = (id, list, label) => {
const $s = $(id).empty();
list.forEach(o => $s.append(new Option(label(o), o.code, false, false)));
};
fill('#fCustomers', d.customers || [], o => `${o.name} (${o.code})`);
fill('#fSalespeople', d.salespeople || [], o => o.code);
fill('#fAreas', d.areas || [], o => o.code);
return d;
});
}
function loadData() {
save();
const p = new URLSearchParams();
p.append('action', 'data');
p.append('metric', metric);
if ($('#ytdToggle').is(':checked')) p.append('ytd', '1');
selectedYears.forEach(y => p.append('years[]', y));
['customers', 'salespeople', 'areas'].forEach(k => {
const id = {
customers: '#fCustomers',
salespeople: '#fSalespeople',
areas: '#fAreas'
} [k];
($(id).val() || []).forEach(v => p.append(k + '[]', v));
});
const seq = ++reqSeq;
$('#content').css('opacity', .55);
fetch('?' + p.toString()).then(r => r.json()).then(d => {
if (seq !== reqSeq) return;
$('#content').css('opacity', 1);
if (!d.success) {
alert(d.message || 'Errore nel caricamento');
return;
}
lastData = d;
selectedYears = d.years.slice();
renderYearChips(d.available);
const empty = !d.available.length;
$('#emptyState').toggle(empty);
$('#content').toggle(!empty);
if (empty) return;
updateTitles();
renderCards(d);
renderMonthly(d);
renderCumul(d);
renderDash(d);
renderInvoiced(d);
renderInvTables();
renderDormant(d);
renderCustomers(d);
}).catch(e => {
$('#content').css('opacity', 1);
console.error(e);
});
}
function updateTitles() {
const v = metric === 'value';
const ytd = $('#ytdToggle').is(':checked');
$('#ytdLabel').text(`A pari data (fino al ${todayLabel()})`);
$('#metricSwitch button').removeClass('active');
$(`#metricSwitch button[data-metric="${metric}"]`).addClass('active');
$('#ttlMonthly').text(v ? 'Valore ordinato per mese' : 'Ordini per mese');
$('#ttlCumul').text(v ? 'Valore progressivo dell\'anno' : 'Ordini progressivi dell\'anno');
$('#ttlCust').text(v ? 'Clienti a confronto · valore ordinato' : 'Clienti a confronto · numero ordini');
}
/* ---------------- CARTE ANNO ---------------- */
function renderCards(d) {
const byYear = {};
d.summary.forEach(r => byYear[+r.y] = r);
const ytd = !!d.ytd;
const missing = d.summary.filter(r => d.years.includes(+r.y)).reduce((a, r) => a + (+r.missing_amounts || 0), 0);
$('#missingAmounts').toggle(missing > 0).html(missing > 0 ?
`<i class="fa-solid fa-circle-exclamation me-1"></i> ${fmtNum(missing)} ordini negli anni scelti non hanno ancora gli importi: lancia una sincronizzazione.` : '');
// riga fatturato nella carta anno (se i documenti sono sincronizzati)
const inv = {};
((d.invoiced && d.invoiced.summary) || []).forEach(r => inv[+r.y] = r);
const invLine = y => {
if (!d.invoiced || !d.invoiced.available) return '';
const r = inv[y],
p = inv[y - 1];
const v = r ? +r.value : 0;
const dl = p ? deltaSpan(pct(v, +p.value), `vs ${y - 1}`) : '';
const cn = r && +r.credit_notes ? ` title="${fmtNum(r.docs)} fatture, ${fmtNum(r.credit_notes)} note di credito (−${esc(fmtEur(r.credit_value, 2))})"` : (r ? ` title="${fmtNum(r.docs)} fatture"` : '');
return `<div class="inv"${cn}><span>Fatturato</span><b>${fmtEurShort(v)}</b>${dl}</div>`;
};
$('#yearCards').html(d.years.slice().reverse().map(y => {
const r = byYear[y] || {
orders: 0,
value: 0,
customers: 0
};
const prev = byYear[y - 1];
const orders = +r.orders,
value = +r.value,
avg = orders ? value / orders : 0;
const scope = ytd ? `al ${todayLabel()}` : (y === CUR_Y ? 'in corso' : 'anno intero');
const deltas = prev ? [
deltaSpan(pct(value, +prev.value), `valore vs ${y - 1}`),
deltaSpan(pct(orders, +prev.orders), `ordini`)
].join('') : `<span>nessun dato ${y - 1}</span>`;
return `
<div class="year-card" style="--yc:${yearColor(y)};">
<div class="y">${y} <small>${scope}</small></div>
<div class="big" title="${esc(fmtEur(value, 2))}">${fmtEurShort(value)}</div>
<div class="stats">
<div><span>Ordini</span><b>${fmtNum(orders)}</b></div>
<div><span>Medio</span><b>${fmtEurShort(avg)}</b></div>
<div><span>Clienti</span><b>${fmtNum(r.customers)}</b></div>
</div>
${invLine(y)}
<div class="deltas">${deltas}</div>
</div>`;
}).join(''));
}
/* ---------------- GRAFICI ---------------- */
function seriesByYear(d, rows) {
const map = {};
d.years.forEach(y => map[y] = Array(12).fill(0));
(rows || d.monthly).forEach(r => {
if (map[+r.y]) map[+r.y][+r.m - 1] = +r.v;
});
// mesi futuri: vuoti (non zero) per l'anno in corso, e per tutti se "a pari data"
const curM = new Date().getMonth();
d.years.forEach(y => {
if (y === CUR_Y || d.ytd) {
for (let i = curM + 1; i < 12; i++) map[y][i] = null;
}
});
return map;
}
function upsert(key, el, opts) {
if (charts[key]) charts[key].updateOptions(opts, true, true);
else {
charts[key] = new ApexCharts(document.getElementById(el), opts);
charts[key].render();
}
}
const base = {
fontFamily: 'inherit',
toolbar: {
show: false
},
animations: {
speed: 350
}
};
function renderMonthly(d, key, el, rows, fmtV, fmtA) {
const map = seriesByYear(d, rows);
upsert(key || 'monthly', el || 'chMonthly', {
chart: {
...base,
type: 'line',
height: 340
},
series: d.years.map(y => ({
name: String(y),
data: map[y]
})),
colors: d.years.map(yearColor),
stroke: {
width: d.years.map(y => y === Math.max(...d.years) ? 4 : 2.5),
curve: 'smooth'
},
markers: {
size: 4
},
xaxis: {
categories: MONTHS
},
yaxis: {
labels: {
formatter: fmtA || fmtAxis
}
},
tooltip: {
shared: true,
intersect: false,
y: {
formatter: fmtV || fmtMetric
}
},
legend: {
position: 'top',
horizontalAlign: 'left'
},
grid: {
borderColor: '#eef2f7'
}
});
}
function renderCumul(d, key, el, rows, fmtV, fmtA) {
const map = seriesByYear(d, rows);
const cum = {};
d.years.forEach(y => {
let s = 0;
cum[y] = map[y].map(v => v === null ? null : (s += v));
});
upsert(key || 'cumul', el || 'chCumul', {
chart: {
...base,
type: 'area',
height: 340
},
series: d.years.map(y => ({
name: String(y),
data: cum[y]
})),
colors: d.years.map(yearColor),
fill: {
type: 'gradient',
gradient: {
opacityFrom: .25,
opacityTo: .02
}
},
stroke: {
width: d.years.map(y => y === Math.max(...d.years) ? 4 : 2.5),
curve: 'smooth'
},
dataLabels: {
enabled: false
},
xaxis: {
categories: MONTHS
},
yaxis: {
labels: {
formatter: fmtA || fmtAxis
}
},
tooltip: {
shared: true,
intersect: false,
y: {
formatter: fmtV || fmtMetric
}
},
legend: {
position: 'top',
horizontalAlign: 'left'
},
grid: {
borderColor: '#eef2f7'
}
});
}
/* ---------------- CRUSCOTTO DIREZIONE ---------------- */
function renderDash(d) {
const x = d.dash || {};
const b = x.backlog || {};
const invOk = !!(d.invoiced && d.invoiced.available);
const cards = [];
// 1) portafoglio da consegnare
const weekly = invOk && x.invoiced_90 > 0 ? x.invoiced_90 / (90 / 7) : (x.ordered_90 > 0 ? x.ordered_90 / (90 / 7) : 0);
const weeks = weekly > 0 && +b.v > 0 ? (+b.v / weekly) : null;
cards.push(`
<div class="dash-card" style="--k:#1d4ed8;" title="Valore residuo delle righe d'ordine ancora da consegnare">
<div class="ico"><i class="fa-solid fa-boxes-stacked"></i></div>
<div class="lbl" data-help="yr.backlog">Portafoglio da consegnare</div>
<div class="big">${fmtEurShort(+b.v || 0)}</div>
<div class="sub">${fmtNum(b.n_lines || 0)} righe · ${fmtNum(b.n_orders || 0)} ordini${weeks !== null ? `<br>≈ <b>${fmtNum(Math.round(weeks))} settimane</b> di ${invOk ? 'fatturato' : 'ordinato'} medio` : ''}${+b.late_v > 0 ? `<br><span class="delta-down">${fmtEurShort(+b.late_v)}</span> già oltre data ZIBO` : ''}</div>
</div>`);
// 2) book-to-bill 90 giorni
if (invOk && x.invoiced_90 > 0) {
const r = x.ordered_90 / x.invoiced_90;
const cls = r >= 1.05 ? 'up' : (r <= 0.95 ? 'down' : 'flat');
const lbl = cls === 'up' ? 'portafoglio in crescita' : (cls === 'down' ? 'portafoglio in calo' : 'stabile');
cards.push(`
<div class="dash-card" style="--k:${cls === 'up' ? '#15803d' : (cls === 'down' ? '#b02a37' : '#b45309')};" title="Ordinato ÷ fatturato negli ultimi 90 giorni">
<div class="ico"><i class="fa-solid fa-scale-balanced"></i></div>
<div class="lbl" data-help="yr.b2b">Book-to-bill · 90 gg</div>
<div class="big">${r.toLocaleString('it-IT', {minimumFractionDigits: 2, maximumFractionDigits: 2})}<span class="b2b-pill ${cls}">${lbl}</span></div>
<div class="sub">Ordinato <b>${fmtEurShort(x.ordered_90)}</b> · fatturato <b>${fmtEurShort(x.invoiced_90)}</b></div>
</div>`);
} else {
cards.push(dashNa('Book-to-bill · 90 gg', 'fa-scale-balanced', 'yr.b2b', invOk ? 'nessun fatturato negli ultimi 90 giorni' : 'serve la sincronizzazione delle fatture'));
}
// 3-4) previsioni di fine anno
cards.push(forecastCard('Previsione fatturato ' + CUR_Y, 'fa-file-invoice-dollar', '#0f766e', 'yr.fcInv', invOk ? x.invoiced_forecast : null, invOk ? '' : 'serve la sincronizzazione delle fatture'));
cards.push(forecastCard('Previsione ordinato ' + CUR_Y, 'fa-chart-line', '#6f42c1', 'yr.fcOrd', x.ordered_forecast, ''));
$('#dashCards').html(cards.join(''));
renderB2BChart(x);
}
function dashNa(label, icon, help, msg) {
return `<div class="dash-card" style="--k:#64748b;"><div class="ico"><i class="fa-solid ${icon}"></i></div>
<div class="lbl" data-help="${help}">${label}</div><div class="big">—</div><div class="sub">${esc(msg)}</div></div>`;
}
function forecastCard(label, icon, color, help, f, naMsg) {
if (!f || f.forecast === null || f.forecast === undefined) {
return dashNa(label, icon, help, naMsg || 'servono dati degli anni precedenti');
}
const vsPrev = f.prev_full ? pct(f.forecast, f.prev_full) : null;
return `
<div class="dash-card" style="--k:${color};" title="Ad oggi ${esc(fmtEur(f.ytd))}: negli anni ${f.years.join(', ')} a questa data si era in media al ${String(f.share).replace('.', ',')}% dell'anno">
<div class="ico"><i class="fa-solid ${icon}"></i></div>
<div class="lbl" data-help="${help}">${label}</div>
<div class="big">${fmtEurShort(f.forecast)}</div>
<div class="sub">forbice ${fmtEurShort(f.low)} – ${fmtEurShort(f.high)}<br>
${f.prev_full ? `${CUR_Y - 1}: ${fmtEurShort(f.prev_full)} ${deltaSpan(vsPrev, '')}` : ''}<br>
<span style="font-size:.76rem;">ad oggi ${fmtEurShort(f.ytd)} · stagionalità ${f.years.join('-')}</span></div>
</div>`;
}
function renderB2BChart(x) {
const keys = [];
const start = new Date(x.since + 'T00:00:00');
for (let i = 0; i < 24; i++) {
const dd = new Date(start.getFullYear(), start.getMonth() + i, 1);
keys.push(dd.getFullYear() + '-' + String(dd.getMonth() + 1).padStart(2, '0'));
}
const toMap = rows => {
const m = {};
(rows || []).forEach(r => m[r.ym] = +r.v);
return m;
};
const om = toMap(x.ordered_months),
im = toMap(x.invoiced_months);
const ord = keys.map(k => om[k] || 0);
const inv = keys.map(k => im[k] || 0);
const hasInv = (x.invoiced_months || []).length > 0;
// book-to-bill mobile su 3 mesi
const b2b = keys.map((k, i) => {
if (!hasInv || i < 2) return null;
const o = ord[i] + ord[i - 1] + ord[i - 2],
f = inv[i] + inv[i - 1] + inv[i - 2];
return f > 0 ? Math.round(o / f * 100) / 100 : null;
});
const cats = keys.map(k => MONTHS[+k.substring(5) - 1] + ' ' + k.substring(2, 4));
const series = [{
name: 'Ordinato',
type: 'column',
data: ord
}];
if (hasInv) series.push({
name: 'Fatturato',
type: 'column',
data: inv
}, {
name: 'Book-to-bill 3 mesi',
type: 'line',
data: b2b
});
const yaxis = [{
seriesName: 'Ordinato',
labels: {
formatter: fmtEurShort
}
}];
if (hasInv) yaxis.push({
seriesName: 'Ordinato',
show: false
}, {
seriesName: 'Book-to-bill 3 mesi',
opposite: true,
min: 0,
max: v => Math.max(2, Math.ceil((v || 0) * 2) / 2),
tickAmount: 4,
labels: {
formatter: v => v === null || v === undefined ? '' : v.toLocaleString('it-IT', {
maximumFractionDigits: 2
})
}
});
upsert('b2b', 'chB2B', {
chart: {
...base,
type: 'line',
height: 320,
stacked: false
},
series: series,
colors: ['#6f42c1', '#0f766e', '#f59e0b'],
stroke: {
width: hasInv ? [0, 0, 3] : [0],
curve: 'smooth'
},
plotOptions: {
bar: {
columnWidth: '62%',
borderRadius: 3
}
},
markers: {
size: hasInv ? [0, 0, 3] : [0]
},
dataLabels: {
enabled: false
},
xaxis: {
categories: cats,
labels: {
rotate: -45,
hideOverlappingLabels: true
}
},
yaxis: yaxis,
annotations: hasInv ? {
yAxis: [{
y: 1,
yAxisIndex: 2,
borderColor: '#f59e0b',
strokeDashArray: 4,
label: {
text: 'parità 1,0',
style: {
background: '#fef3c7',
color: '#92400e'
}
}
}]
} : {},
tooltip: {
shared: true,
intersect: false,
y: {
formatter: (v, o) => v === null || v === undefined ? '—' : (o && o.seriesIndex === 2 ? v.toLocaleString('it-IT', {
minimumFractionDigits: 2
}) : fmtEur(v))
}
},
legend: {
position: 'top',
horizontalAlign: 'left'
},
grid: {
borderColor: '#eef2f7'
}
});
}
/* ---------------- CLIENTI DA RICHIAMARE ---------------- */
let dormDt = null,
dormLevel = '';
const LVL = {
watch: 'Attenzione',
dormant: 'Dormiente',
lost: 'Probabilmente perso'
};
function renderDormant(d) {
const all = d.dormant || [];
const cfg = d.dormant_cfg || {};
const cnt = {
watch: 0,
dormant: 0,
lost: 0
};
all.forEach(c => cnt[c.level]++);
$('#dCntAll').text(fmtNum(all.length));
$('#dCntWatch').text(cnt.watch);
$('#dCntDormant').text(cnt.dormant);
$('#dCntLost').text(cnt.lost);
$('#dormChips .chip').removeClass('active');
$(`#dormChips .chip[data-level="${dormLevel}"]`).addClass('active');
$('#dormHint').text(`Clienti con almeno ${cfg.min_orders} ordini negli ultimi ${cfg.years} anni che ora tardano rispetto al loro ritmo abituale · clicca per filtrare la pagina`);
const rows = all.filter(c => !dormLevel || c.level === dormLevel);
const risk = rows.reduce((a, c) => a + (+c.value12 || 0), 0);
$('#dormSum').html(rows.length ?
`<b>${fmtNum(rows.length)}</b> clienti · <b>${fmtEurShort(risk)}</b> di ordinato annuo a rischio (ordinato dei 12 mesi prima del loro ultimo ordine)` :
'Nessun cliente da richiamare con i filtri attuali 👍');
if (dormDt) {
dormDt.destroy();
$('#dormTable').empty();
}
dormDt = $('#dormTable').DataTable({
data: rows,
columns: [{
title: 'Cliente',
data: null,
render: (c, type) => type === 'display' ?
`<div class="fw-semibold">${esc(c.name)}</div><div class="small text-muted">${esc(c.code)}<a class="sc-link" href="sales_customer.php?c=${encodeURIComponent(c.code)}" title="Apri la scheda cliente" onclick="event.stopPropagation();"><i class="fa-solid fa-address-card"></i></a></div>` : (type === 'export' ? `${c.name} (${c.code})` : `${c.name} ${c.code}`)
}, {
title: 'Agente',
data: 'salesperson'
}, {
title: 'Area',
data: 'area'
}, {
title: 'Ultimo ordine',
data: 'last_date',
render: (v, type, c) => {
if (type === 'export') return v;
if (type !== 'display') return v;
const p = v.split('-');
return `<div>${p[2]}/${p[1]}/${p[0]} <span class="small text-muted">${esc(c.last_no)}</span></div><div class="small text-muted">${fmtNum(c.days_since)} giorni fa</div>`;
}
}, {
title: 'Ritmo abituale',
data: 'rhythm',
className: 'text-end',
render: (v, type) => type === 'display' ? `ogni ~${fmtNum(v)} gg` : +v
}, {
title: 'Ritardo',
data: 'ratio',
className: 'text-end text-nowrap',
render: (v, type, c) => {
if (type !== 'display') return +v;
const w = Math.min(100, v / (cfg.lost || 4) * 100);
const col = c.level === 'lost' ? '#dc2626' : (c.level === 'dormant' ? '#ea580c' : '#ca8a04');
return `<b>${String(v).replace('.', ',')}×</b><span class="ratio-bar"><i style="width:${w}%;background:${col};"></i></span>`;
}
}, {
title: `Ordini ${cfg.years} anni`,
data: 'orders',
className: 'text-end'
}, {
title: 'Ordinato 12 mesi',
data: 'value12',
className: 'text-end amount',
render: (v, type) => type === 'display' ? fmtEur(+v) : +v
}, {
title: 'Stato',
data: 'level',
render: (v, type) => type === 'display' ? `<span class="lvl ${v}">${LVL[v]}</span>` : LVL[v]
}],
order: [
[7, 'desc']
],
pageLength: 10,
lengthMenu: [10, 25, 50, 100],
dom: "<'row mb-2'<'col-md-6'B><'col-md-6'f>>rt<'row mt-2'<'col-md-5'i><'col-md-7'p>>",
buttons: xlsButton('Clienti da richiamare' + (dormLevel ? ' - ' + LVL[dormLevel] : '') + invFiltersLabel(true)),
language: {
url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json'
}
});
$('#dormTable tbody').off('click').on('click', 'tr', function() {
const c = dormDt.row(this).data();
if (!c) return;
$('#fCustomers').val([c.code]).trigger('change');
window.scrollTo({
top: 0,
behavior: 'smooth'
});
});
}
/* ---------------- TABELLA DATI FATTURATO (a scomparsa) ---------------- */
let invMonthDt = null,
invDocDt = null,
invTab = 'months',
invDocsKey = '';
const xlsButton = title => ({
dom: {
button: {
className: 'btn'
}
},
buttons: [{
extend: 'excelHtml5',
text: '<i class="fa-solid fa-file-excel me-1"></i> Excel',
className: 'btn btn-outline-success btn-sm',
title: title,
footer: true,
exportOptions: {
orthogonal: 'export',
format: {
header: h => $('<div>').html(h).text().replace('●', '').trim(),
// importi del totale come numeri in Excel; testo e percentuali lasciati com'erano
footer: h => {
const t = $('<div>').html(h).text().replace(/\u00a0/g, ' ').trim();
if (/^-?[\d.]+(,\d+)?\s*€$/.test(t)) return t.replace(/\./g, '').replace('€', '').replace(',', '.').trim();
if (/^[+-]?\d+%$/.test(t)) return parseInt(t, 10) / 100;
return t;
}
}
}
}]
});
function invOpen() {
return $('#invToggle').attr('aria-expanded') === 'true';
}
function invFiltersLabel(noYtd) {
const parts = [];
const n = (id, l) => {
const v = $(id).val() || [];
if (v.length) parts.push(`${l}: ${v.join(', ')}`);
};
n('#fCustomers', 'clienti');
n('#fSalespeople', 'agenti');
n('#fAreas', 'aree');
if (!noYtd && lastData && lastData.ytd) parts.push(`a pari data ${todayLabel()}`);
return parts.length ? ' (' + parts.join(' · ') + ')' : '';
}
function renderInvTables() {
if (!lastData || !lastData.invoiced || !lastData.invoiced.available || !invOpen()) return;
if (invTab === 'months') renderInvMonthTable(lastData);
else loadInvDocs();
}
function renderInvMonthTable(d) {
const years = d.years.slice().reverse();
const map = seriesByYear(d, d.invoiced.monthly || []);
const rows = MONTHS.map((m, i) => ({
m: i,
label: m
}));
const cum = {};
years.forEach(y => {
let s = 0;
cum[y] = map[y].map(v => v === null ? null : (s += v));
});
const money = v => v === null || v === undefined ? '<span class="text-muted">—</span>' : fmtEur(v);
const columns = [{
title: 'Mese',
data: 'label',
render: (v, type, r) => type === 'display' || type === 'export' ? v.charAt(0).toUpperCase() + v.slice(1) : r.m
}];
years.forEach((y, k) => {
columns.push({
title: `<span style="color:${yearColor(y)};">●</span> ${y}`,
data: null,
className: 'text-end amount',
render: (r, type) => {
const v = map[y][r.m];
return type === 'display' ? money(v) : (v === null ? '' : Math.round(v * 100) / 100);
}
}, {
title: `${y} progr.`,
data: null,
className: 'text-end text-muted',
render: (r, type) => {
const v = cum[y][r.m];
return type === 'display' ? money(v) : (v === null ? '' : Math.round(v * 100) / 100);
}
});
const py = years[k + 1];
if (k === 0 && py) {
columns.push({
title: `Var. ${y} vs ${py}`,
data: null,
className: 'text-end',
render: (r, type) => {
const a = map[y][r.m],
b = map[py][r.m];
const p = (a === null || !b) ? null : (a - b) / Math.abs(b) * 100;
if (type === 'export') return p === null ? '' : Math.round(p) / 100;
if (type !== 'display') return p === null ? -1e9 : p;
if (p === null) return '<span class="text-muted">—</span>';
const rr = Math.round(p);
return `<span class="${rr >= 0 ? 'delta-up' : 'delta-down'}">${rr >= 0 ? '+' : ''}${rr}%</span>`;
}
});
}
});
// riga totale
const tot = years.map(y => map[y].reduce((a, v) => a + (v || 0), 0));
let foot = '<tfoot><tr><th>Totale</th>';
years.forEach((y, k) => {
foot += `<th class="text-end">${fmtEur(tot[k], 2)}</th><th></th>`;
if (k === 0 && years[1]) {
const p = tot[1] ? (tot[0] - tot[1]) / Math.abs(tot[1]) * 100 : null;
foot += `<th class="text-end">${p === null ? '' : `<span class="${p >= 0 ? 'delta-up' : 'delta-down'}">${p >= 0 ? '+' : ''}${Math.round(p)}%</span>`}</th>`;
}
});
foot += '</tr></tfoot>';
if (invMonthDt) {
invMonthDt.destroy();
$('#invMonthTable').empty();
}
$('#invMonthTable').html(foot);
invMonthDt = $('#invMonthTable').DataTable({
data: rows,
columns: columns,
order: [
[0, 'asc']
],
paging: false,
searching: false,
info: false,
dom: "<'mb-2'B>rt",
buttons: xlsButton('Fatturato per mese' + invFiltersLabel()),
language: {
url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json'
}
});
}
function loadInvDocs() {
const p = new URLSearchParams();
p.append('action', 'invdocs');
if (lastData.ytd) p.append('ytd', '1');
lastData.years.forEach(y => p.append('years[]', y));
[
['customers', '#fCustomers'],
['salespeople', '#fSalespeople'],
['areas', '#fAreas']
].forEach(([k, id]) => ($(id).val() || []).forEach(v => p.append(k + '[]', v)));
const key = p.toString();
if (key === invDocsKey && invDocDt) return; // già caricati con questi filtri
invDocsKey = key;
$('#invPaneDocs').css('opacity', .5);
fetch('?' + key).then(r => r.json()).then(res => {
$('#invPaneDocs').css('opacity', 1);
if (!res.success) {
alert(res.message || 'Errore nel caricamento documenti');
return;
}
renderInvDocTable(res.docs || [], res.types || {});
}).catch(e => {
$('#invPaneDocs').css('opacity', 1);
console.error(e);
});
}
function renderInvDocTable(docs, types) {
const cls = r => +r.is_credit_note ? 't-cn' : (+r.document_type === 3407874 ? 't-inv' : 't-adv');
const total = docs.reduce((a, r) => a + (+r.revenue || 0), 0);
const nc = docs.filter(r => +r.is_credit_note).length;
if (invDocDt) {
invDocDt.destroy();
$('#invDocTable').empty();
}
$('#invDocTable').html(`<tfoot><tr><th>Totale</th><th>${fmtNum(docs.length - nc)} fatture</th><th>${fmtNum(nc)} note di credito</th><th></th><th></th><th></th><th class="text-end">${fmtEur(total, 2)}</th></tr></tfoot>`);
invDocDt = $('#invDocTable').DataTable({
data: docs,
columns: [{
title: 'Data',
data: 'document_date',
render: (v, type) => type === 'display' ? fmtDateIt(v) : v
}, {
title: 'N° documento',
data: 'doc_no',
className: 'text-nowrap'
}, {
title: 'Tipo',
data: 'document_type',
render: (v, type, r) => {
const l = types[v] || v;
return type === 'display' ? `<span class="doc-type ${cls(r)}">${esc(l)}</span>` : l;
}
}, {
title: 'Cliente',
data: null,
render: (r, type) => type === 'display' ?
`<div class="fw-semibold">${esc(r.company_name)}</div><div class="small text-muted">${esc(r.customer)}</div>` : (type === 'export' ? `${r.company_name} (${r.customer})` : `${r.company_name} ${r.customer}`)
}, {
title: 'Agente',
data: 'salesperson'
}, {
title: 'Area',
data: 'area'
}, {
title: 'Imponibile',
data: 'revenue',
className: 'text-end amount',
render: (v, type, r) => {
if (type !== 'display') return +v;
if (+r.no_amount) return '<span class="text-muted" title="Importo non ancora sincronizzato">—</span>';
return `<span class="${+v < 0 ? 'delta-down' : ''}">${fmtEur(+v, 2)}</span>`;
}
}],
order: [
[0, 'desc']
],
pageLength: 25,
lengthMenu: [10, 25, 50, 100, 500],
dom: "<'row mb-2'<'col-md-6'B><'col-md-6'f>>rt<'row mt-2'<'col-md-5'i><'col-md-7'p>>",
buttons: xlsButton('Fatture e note di credito' + invFiltersLabel()),
language: {
url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json'
}
});
}
function fmtDateIt(v) {
if (!v) return '';
const p = String(v).substring(0, 10).split('-');
return `${p[2]}/${p[1]}/${p[0]}`;
}
// fatturato: sempre in € (indipendente dal selettore N° ordini / Valore)
function renderInvoiced(d) {
const inv = d.invoiced || {};
$('#invRow, #invTableCard').toggle(!!inv.available);
const miss = (inv.summary || []).filter(r => d.years.includes(+r.y)).reduce((a, r) => a + (+r.missing_amounts || 0), 0);
$('#invMissing').toggle(!inv.available || miss > 0).html(!inv.available ?
'<i class="fa-solid fa-circle-info me-1"></i> Fatturato non ancora disponibile: eseguire la migrazione <b>mago_sale_doc</b> e una sincronizzazione.' :
`<i class="fa-solid fa-circle-exclamation me-1"></i> ${fmtNum(miss)} fatture negli anni scelti non hanno ancora l'importo: lancia una sincronizzazione.`);
if (!inv.available) return;
const fv = v => v === null || v === undefined ? '—' : fmtEur(v);
renderMonthly(d, 'invMonthly', 'chInvMonthly', inv.monthly || [], fv, fmtEurShort);
renderCumul(d, 'invCumul', 'chInvCumul', inv.monthly || [], fv, fmtEurShort);
}
/* ---------------- CLIENTI ---------------- */
function classify(c, last, prev) {
const a = c.v[last] || 0,
b = prev ? (c.v[prev] || 0) : 0;
if (!prev) return {
t: 'flat',
p: null
};
if (b === 0 && a > 0) return {
t: 'new',
p: null
};
if (b > 0 && a === 0) return {
t: 'lost',
p: -100
};
const p = pct(a, b);
if (p === null) return {
t: 'flat',
p: null
};
if (p >= 10) return {
t: 'up',
p
};
if (p <= -10) return {
t: 'down',
p
};
return {
t: 'flat',
p
};
}
function trendBadge(c) {
const map = {
new: ['trend-new', '★ Nuovo'],
lost: ['trend-lost', '✕ Perso'],
up: ['trend-up', '▲ Crescita'],
down: ['trend-down', '▼ Calo'],
flat: ['trend-flat', '= Stabile']
};
const [cls, txt] = map[c.t];
return `<span class="trend ${cls}">${txt}</span>`;
}
function renderCustomers(d) {
const years = d.years;
const last = years[years.length - 1];
const prev = years.length > 1 ? years[years.length - 2] : null;
const all = d.customers.map(c => Object.assign({}, c, classify(c, last, prev)));
const counts = {
new: 0,
lost: 0,
up: 0,
down: 0
};
all.forEach(c => {
if (counts[c.t] !== undefined) counts[c.t]++;
});
$('#cntAll').text(fmtNum(all.length));
$('#cntNew').text(counts.new);
$('#cntLost').text(counts.lost);
$('#cntUp').text(counts.up);
$('#cntDown').text(counts.down);
$('#trendChips .chip').removeClass('active');
$(`#trendChips .chip[data-trend="${trendFilter}"]`).addClass('active');
$('#custHint').text(prev ?
`Andamento: ${last} rispetto a ${prev} (soglia ±10%) · clicca un cliente per filtrare tutta la pagina` :
'Seleziona almeno due anni per vedere nuovi, persi, crescita e calo');
const rows = all.filter(c => !trendFilter || c.t === trendFilter);
const columns = [{
title: 'Cliente',
data: null,
render: (c, type) => type === 'display' ?
`<div class="fw-semibold">${esc(c.name)}</div><div class="small text-muted">${esc(c.code)}<a class="sc-link" href="sales_customer.php?c=${encodeURIComponent(c.code)}" title="Apri la scheda cliente" onclick="event.stopPropagation();"><i class="fa-solid fa-address-card"></i></a></div>` : (type === 'export' ? `${c.name} (${c.code})` : `${c.name} ${c.code}`)
}];
years.slice().reverse().forEach(y => columns.push({
title: `<span style="color:${yearColor(y)};">●</span> ${y}`,
data: null,
className: 'text-end amount',
render: (c, type) => {
const v = c.v[y] || 0;
if (type !== 'display') return v;
return v ? fmtMetric(v) : '<span class="text-muted">—</span>';
}
}));
columns.push({
title: prev ? `Var. ${last} vs ${prev}` : 'Var.',
data: null,
className: 'text-end',
render: (c, type) => {
if (type === 'export') return c.p === null ? '' : Math.round(c.p) / 100;
if (type !== 'display') return c.p === null ? (c.t === 'new' ? 1e9 : -1e9) : c.p;
if (c.p === null) return '<span class="text-muted">—</span>';
const r = Math.round(c.p);
return `<span class="${r >= 0 ? 'delta-up' : 'delta-down'}">${r >= 0 ? '+' : ''}${r}%</span>`;
}
}, {
title: 'Andamento',
data: null,
render: (c, type) => type === 'display' ? trendBadge(c) : (type === 'export' ? $('<div>').html(trendBadge(c)).text().replace(/^[^A-Za-zÀ-ú]+/, '') : c.t)
});
if (table) {
table.destroy();
$('#custTable').empty();
}
table = $('#custTable').DataTable({
data: rows,
columns: columns,
order: [
[1, 'desc']
],
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
dom: "<'row mb-2'<'col-md-6'B><'col-md-6'f>>rt<'row mt-2'<'col-md-5'i><'col-md-7'p>>",
buttons: {
dom: {
button: {
className: 'btn'
}
},
buttons: [{
extend: 'excelHtml5',
text: '<i class="fa-solid fa-file-excel me-1"></i> Excel',
className: 'btn btn-outline-success btn-sm',
title: 'Confronto clienti per anno',
exportOptions: {
orthogonal: 'export',
format: {
header: h => $('<div>').html(h).text().replace('●', '').trim()
}
}
}]
},
language: {
url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json'
}
});
$('#custTable tbody').off('click').on('click', 'tr', function() {
const c = table.row(this).data();
if (!c) return;
$('#fCustomers').val([c.code]).trigger('change');
window.scrollTo({
top: 0,
behavior: 'smooth'
});
});
}
/* ---------------- EVENTI ---------------- */
$(function() {
['#fCustomers', '#fSalespeople', '#fAreas'].forEach((id, i) => {
$(id).select2({
theme: 'bootstrap-5',
placeholder: ['Tutti i clienti', 'Tutti', 'Tutte'][i],
allowClear: true,
closeOnSelect: false,
width: '100%'
}).on('change', () => scheduleLoad());
});
$('#yearChips').on('click', '.chip', function() {
const y = +$(this).data('year');
if (selectedYears.includes(y)) {
if (selectedYears.length === 1) return; // almeno un anno
selectedYears = selectedYears.filter(x => x !== y);
} else {
selectedYears.push(y);
}
selectedYears.sort();
$(this).toggleClass('active');
scheduleLoad(0);
});
$('#ytdToggle').on('change', () => scheduleLoad(0));
$('#metricSwitch').on('click', 'button', function() {
metric = $(this).data('metric');
scheduleLoad(0);
});
// clienti da richiamare: filtro per livello
$('#dormChips').on('click', '.chip', function() {
dormLevel = $(this).data('level') || '';
if (lastData) renderDormant(lastData);
});
// tabella dati fatturato: apri/chiudi (stato ricordato) e schede
$('#invToggle').on('click', function() {
const open = !invOpen();
$(this).attr('aria-expanded', open ? 'true' : 'false');
$('#invTableBody').stop(true, true)[open ? 'slideDown' : 'slideUp'](180, () => {
if (open) renderInvTables();
});
try {
localStorage.setItem(STORE_KEY + '_inv', JSON.stringify({
open: open,
tab: invTab
}));
} catch (e) {}
});
$('#invToggle').on('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
$(this).trigger('click');
}
});
$('#invTabs').on('click', 'button', function() {
invTab = $(this).data('tab');
$('#invTabs button').removeClass('active');
$(this).addClass('active');
$('#invPaneMonths').toggle(invTab === 'months');
$('#invPaneDocs').toggle(invTab === 'docs');
renderInvTables();
try {
localStorage.setItem(STORE_KEY + '_inv', JSON.stringify({
open: invOpen(),
tab: invTab
}));
} catch (e) {}
});
try {
const st = JSON.parse(localStorage.getItem(STORE_KEY + '_inv') || 'null');
if (st) {
invTab = st.tab === 'docs' ? 'docs' : 'months';
$('#invTabs button').removeClass('active');
$(`#invTabs button[data-tab="${invTab}"]`).addClass('active');
$('#invPaneMonths').toggle(invTab === 'months');
$('#invPaneDocs').toggle(invTab === 'docs');
if (st.open) {
$('#invToggle').attr('aria-expanded', 'true');
$('#invTableBody').show();
}
}
} catch (e) {}
$('#trendChips').on('click', '.chip', function() {
trendFilter = $(this).data('trend') || '';
if (lastData) renderCustomers(lastData);
});
$('#btnReset').on('click', function() {
$('#fCustomers, #fSalespeople, #fAreas').val(null).trigger('change.select2');
trendFilter = '';
selectedYears = [];
$('#ytdToggle').prop('checked', true);
scheduleLoad(0);
});
loadFilters().then(() => {
const s = restore();
if (s) {
selectedYears = (s.years || []).map(Number);
metric = s.metric === 'count' ? 'count' : 'value';
$('#ytdToggle').prop('checked', s.ytd !== false);
$('#fCustomers').val(s.customers || []).trigger('change.select2');
$('#fSalespeople').val(s.salespeople || []).trigger('change.select2');
$('#fAreas').val(s.areas || []).trigger('change.select2');
}
updateTitles();
loadData();
});
});
})();
</script>
<script src="sales_help.js?v=1"></script>
</body>
</html>