2383 lines
99 KiB
PHP
2383 lines
99 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));
|
||
}
|
||
|
||
// 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;
|
||
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; ?>
|
||
</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 -->
|
||
<!-- CRUSCOTTO DIREZIONE: situazione ad oggi -->
|
||
<div class="dash-head">
|
||
<span><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">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="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>
|
||
|
||
<!-- 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">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">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">
|
||
<button type="button" class="inv-toggle" id="invToggle" aria-expanded="false">
|
||
<span><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>
|
||
</button>
|
||
<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"><i class="fa-solid fa-phone-volume me-2" style="color:#c2410c;"></i>Clienti da richiamare</div>
|
||
<div class="chart-hint" id="dormHint"> </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">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 => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
} [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">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">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', 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', invOk ? x.invoiced_forecast : null, invOk ? '' : 'serve la sincronizzazione delle fatture'));
|
||
cards.push(forecastCard('Previsione ordinato ' + CUR_Y, 'fa-chart-line', '#6f42c1', x.ordered_forecast, ''));
|
||
|
||
$('#dashCards').html(cards.join(''));
|
||
renderB2BChart(x);
|
||
}
|
||
|
||
function dashNa(label, icon, msg) {
|
||
return `<div class="dash-card" style="--k:#64748b;"><div class="ico"><i class="fa-solid ${icon}"></i></div>
|
||
<div class="lbl">${label}</div><div class="big">—</div><div class="sub">${esc(msg)}</div></div>`;
|
||
}
|
||
|
||
function forecastCard(label, icon, color, f, naMsg) {
|
||
if (!f || f.forecast === null || f.forecast === undefined) {
|
||
return dashNa(label, icon, 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">${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) {}
|
||
});
|
||
$('#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>
|
||
</body>
|
||
|
||
</html>
|