Files
zibo-dashboard/public/userarea/sales_orders_years.php
T
2026-10-13 16:30:47 +02:00

1287 lines
49 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;
}
/* ============================================================
* AJAX
* ============================================================ */
$action = $_GET['action'] ?? '';
if ($action === 'filters') {
MagoSalesStats::jsonOut(MagoSalesStats::filterOptions($pdo));
}
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 = [];
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);
}
MagoSalesStats::jsonOut([
'success' => true,
'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;
}
.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);
}
</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_orders_deliveries.php"><i class="fa-solid fa-truck-fast me-1"></i> Consegne</a>
</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">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 -->
<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">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">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>
<!-- 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">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);
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.` : '');
$('#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>
<div class="deltas">${deltas}</div>
</div>`;
}).join(''));
}
/* ---------------- GRAFICI ---------------- */
function seriesByYear(d) {
const map = {};
d.years.forEach(y => map[y] = Array(12).fill(0));
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) {
const map = seriesByYear(d);
upsert('monthly', '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: fmtAxis
}
},
tooltip: {
shared: true,
intersect: false,
y: {
formatter: fmtMetric
}
},
legend: {
position: 'top',
horizontalAlign: 'left'
},
grid: {
borderColor: '#eef2f7'
}
});
}
function renderCumul(d) {
const map = seriesByYear(d);
const cum = {};
d.years.forEach(y => {
let s = 0;
cum[y] = map[y].map(v => v === null ? null : (s += v));
});
upsert('cumul', '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: fmtAxis
}
},
tooltip: {
shared: true,
intersect: false,
y: {
formatter: fmtMetric
}
},
legend: {
position: 'top',
horizontalAlign: 'left'
},
grid: {
borderColor: '#eef2f7'
}
});
}
/* ---------------- 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)}</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);
});
$('#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>
</body>
</html>