Files
zibo-dashboard/public/userarea/sales_orders_stats.php
T
2026-10-13 14:29:00 +02:00

1981 lines
86 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
ob_start();
ini_set('display_errors', 1);
error_reporting(E_ALL);
include('include/headscript.php');
require_once __DIR__ . '/class/MagoSync.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;
}
$canSync = userCan('sales.sync.manage');
/* ============================================================
* AJAX
* ============================================================ */
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
if ($action === 'sync' && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$canSync) {
MagoSalesStats::jsonOut(['ok' => false, 'message' => 'Non hai i permessi per sincronizzare i dati da Mago']);
}
set_time_limit(600);
try {
$mode = $_POST['mode'] ?? 'incremental';
$opts = [];
if ($mode === 'test') $opts['limit'] = max(1, min(500, (int)($_POST['limit'] ?? 50)));
if ($mode === 'full') $opts['full'] = true;
$sync = new MagoSync($pdo, MagoClient::fromEnv());
$report = $sync->run($opts);
$report['info'] = MagoSalesStats::syncInfo($pdo);
MagoSalesStats::jsonOut($report);
} catch (Throwable $e) {
MagoSalesStats::jsonOut(['ok' => false, 'message' => $e->getMessage()]);
}
}
if ($action === 'filters') {
MagoSalesStats::jsonOut(MagoSalesStats::filterOptions($pdo));
}
if ($action === 'data') {
try {
[$where, $params] = MagoSalesStats::buildWhere();
$from = MagoSalesStats::baseFrom();
// Metrica dei grafici: numero ordini oppure valore (imponibile, annullati esclusi)
$metric = (($_GET['metric'] ?? '') === 'value') ? 'value' : 'count';
$m = $metric === 'value' ? "CASE WHEN x.status <> 'annullato' THEN x.taxable_amount ELSE 0 END" : "1";
$agg = "SUM($m)";
$val = "CASE WHEN x.status <> 'annullato' THEN x.taxable_amount ELSE 0 END";
// --- KPI ---
$kpi = MagoSalesStats::fetchAllQ($pdo, "
SELECT
COUNT(*) AS orders,
SUM(x.status = 'aperto') AS open_orders,
SUM(x.status = 'evaso') AS delivered,
SUM(x.status = 'annullato') AS cancelled,
SUM(x.status = 'bloccato') AS blocked,
SUM(x.status IN ('aperto','bloccato') AND x.due_date < CURDATE()) AS late,
SUM(x.delivered = 1 AND x.invoiced = 0 AND x.cancelled = 0) AS to_invoice,
COUNT(DISTINCT x.customer) AS customers,
AVG(CASE WHEN x.status <> 'annullato' AND x.due_date IS NOT NULL AND x.due_date >= x.order_date
THEN DATEDIFF(x.due_date, x.order_date) END) AS avg_lead_days,
COALESCE(SUM($val), 0) AS value_total,
COALESCE(SUM(CASE WHEN x.status IN ('aperto','bloccato') THEN x.taxable_amount ELSE 0 END), 0) AS value_open,
COALESCE(SUM(CASE WHEN x.status IN ('aperto','bloccato') AND x.due_date < CURDATE() THEN x.taxable_amount ELSE 0 END), 0) AS value_late,
COALESCE(SUM(CASE WHEN x.delivered = 1 AND x.invoiced = 0 AND x.cancelled = 0 THEN x.taxable_amount ELSE 0 END), 0) AS value_to_invoice,
AVG(CASE WHEN x.status <> 'annullato' THEN x.taxable_amount END) AS value_avg,
SUM(x.summary_tb_modified IS NULL) AS missing_amounts
FROM $from
WHERE $where
", $params)[0];
// --- Confronto con lo STESSO PERIODO dell'anno precedente ---
$kpi['prev_orders'] = null;
$kpi['prev_value'] = null;
$fromD = $_GET['from'] ?? '';
$toD = $_GET['to'] ?? '';
if (MagoSalesStats::isValidDate($fromD) && MagoSalesStats::isValidDate($toD) && $fromD <= $toD) {
$prevFrom = date('Y-m-d', strtotime($fromD . ' -1 year'));
$prevTo = date('Y-m-d', strtotime($toD . ' -1 year'));
[$pw, $pp] = MagoSalesStats::buildWhere(['from' => $prevFrom, 'to' => $prevTo]);
$prev = MagoSalesStats::fetchAllQ($pdo, "SELECT COUNT(*) AS n, COALESCE(SUM($val), 0) AS v FROM $from WHERE $pw", $pp)[0];
$kpi['prev_orders'] = (int)$prev['n'];
$kpi['prev_value'] = (float)$prev['v'];
$kpi['prev_from'] = date('d/m/Y', strtotime($prevFrom));
$kpi['prev_to'] = date('d/m/Y', strtotime($prevTo));
}
// --- Andamento mensile per stato ---
$monthly = MagoSalesStats::fetchAllQ($pdo, "
SELECT DATE_FORMAT(x.order_date, '%Y-%m') AS ym,
SUM(CASE WHEN x.status = 'aperto' THEN $m ELSE 0 END) AS aperto,
SUM(CASE WHEN x.status = 'bloccato' THEN $m ELSE 0 END) AS bloccato,
SUM(CASE WHEN x.status = 'evaso' THEN $m ELSE 0 END) AS evaso,
SUM(CASE WHEN x.status = 'annullato' THEN " . ($metric === 'value' ? '0' : '1') . " ELSE 0 END) AS annullato,
$agg AS totale
FROM $from
WHERE $where AND x.order_date IS NOT NULL
GROUP BY ym
ORDER BY ym
", $params);
// --- Top clienti ---
$topCustomers = MagoSalesStats::fetchAllQ($pdo, "
SELECT x.customer AS code, x.company_name AS name,
$agg AS n,
SUM(CASE WHEN x.status IN ('aperto','bloccato') THEN $m ELSE 0 END) AS open_n
FROM $from
WHERE $where
GROUP BY x.customer, x.company_name
ORDER BY n DESC
LIMIT 10
", $params);
// --- Per area / agente ---
$byArea = MagoSalesStats::fetchAllQ($pdo, "
SELECT CASE WHEN x.area = '' THEN '(nessuna)' ELSE x.area END AS label, x.area AS code, $agg AS n
FROM $from WHERE $where
GROUP BY x.area ORDER BY n DESC LIMIT 12
", $params);
$bySalesperson = MagoSalesStats::fetchAllQ($pdo, "
SELECT CASE WHEN x.salesperson = '' THEN '(nessuno)' ELSE x.salesperson END AS label, x.salesperson AS code, $agg AS n
FROM $from WHERE $where
GROUP BY x.salesperson ORDER BY n DESC LIMIT 12
", $params);
// --- Scadenze ordini aperti ---
$dueBuckets = MagoSalesStats::fetchAllQ($pdo, "
SELECT bucket, SUM(mv) AS n FROM (
SELECT CASE
WHEN x.due_date IS NULL THEN '6_senza'
WHEN x.due_date < CURDATE() THEN '1_scaduti'
WHEN YEARWEEK(x.due_date, 3) = YEARWEEK(CURDATE(), 3) THEN '2_settimana'
WHEN YEARWEEK(x.due_date, 3) = YEARWEEK(CURDATE() + INTERVAL 7 DAY, 3) THEN '3_prossima'
WHEN x.due_date <= CURDATE() + INTERVAL 30 DAY THEN '4_30gg'
ELSE '5_oltre'
END AS bucket,
$m AS mv
FROM $from
WHERE $where AND x.status IN ('aperto','bloccato')
) b
GROUP BY bucket ORDER BY bucket
", $params);
// --- Elenco ordini ---
$rows = MagoSalesStats::fetchAllQ($pdo, "
SELECT x.sale_ord_id, x.internal_ord_no, x.external_ord_no,
x.order_date, x.due_date, x.customer, x.company_name,
x.salesperson, x.area, x.status, x.invoiced, x.your_reference,
x.taxable_amount, x.total_amount,
(x.summary_tb_modified IS NULL) AS no_amount,
(x.status IN ('aperto','bloccato') AND x.due_date < CURDATE()) AS is_late
FROM $from
WHERE $where
ORDER BY x.order_date DESC, x.sale_ord_id DESC
LIMIT 5000
", $params);
MagoSalesStats::jsonOut([
'success' => true,
'metric' => $metric,
'kpi' => $kpi,
'monthly' => $monthly,
'top_customers' => $topCustomers,
'by_area' => $byArea,
'by_salesperson' => $bySalesperson,
'due_buckets' => $dueBuckets,
'rows' => $rows,
]);
} catch (Throwable $e) {
MagoSalesStats::jsonOut(['success' => false, 'message' => $e->getMessage()]);
}
}
if ($action === 'order') {
try {
$id = (int)($_GET['id'] ?? 0);
$st = $pdo->prepare("SELECT x.* FROM " . MagoSalesStats::baseFrom() . " WHERE x.sale_ord_id = ?");
$st->execute([$id]);
$o = $st->fetch(PDO::FETCH_ASSOC);
if (!$o) MagoSalesStats::jsonOut(['success' => false, 'message' => 'Ordine non trovato']);
$raw = json_decode((string)$o['raw_json'], true) ?: [];
unset($o['raw_json']);
MagoSalesStats::jsonOut([
'success' => true,
'order' => $o,
'extra' => [
'Cliente fatturazione' => $raw['InvoicingCustomer'] ?? '',
'Pagamento' => $raw['Payment'] ?? '',
'Listino' => $raw['PriceList'] ?? '',
'Trasporto' => $raw['Transport'] ?? '',
'Porto' => $raw['Port'] ?? '',
'Imballo' => $raw['Package'] ?? '',
'Magazzino' => $raw['StoragePhase1'] ?? '',
'Nostro riferimento' => $raw['OurReference'] ?? '',
'Vostro riferimento' => $raw['YourReference'] ?? '',
'Commessa' => $raw['Job'] ?? '',
'Stampato' => !empty($raw['Printed']) ? 'Sì' : 'No',
'Fatturato' => !empty($raw['Invoiced']) ? 'Sì' : 'No',
'Consegna tassativa' => MagoClient::toMysqlDate($raw['CompulsoryDeliveryDate'] ?? null),
'Creato in Mago' => MagoClient::toMysqlDate($raw['TbCreated'] ?? null, true),
'Modificato in Mago' => MagoClient::toMysqlDate($raw['TbModified'] ?? null, true),
],
]);
} 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>Statistiche Ordini Cliente - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
<!-- jQuery / SweetAlert -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></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-open: #0d6efd;
--c-done: #198754;
--c-cancel: #94a3b8;
--c-block: #fd7e14;
--c-late: #dc3545;
--c-invoice: #6f42c1;
--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;
}
.btn-sync {
background: linear-gradient(135deg, #0d6efd, #3b82f6);
color: #fff;
border: 0;
border-radius: 10px;
padding: 10px 16px;
font-weight: 700;
}
.btn-sync:hover,
.btn-sync:focus {
color: #fff;
opacity: .95;
}
/* ---------- 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 {
font-weight: 700;
font-size: .8rem;
color: var(--c-muted);
text-transform: uppercase;
letter-spacing: .4px;
margin-bottom: 4px;
}
.chip {
border: 1.5px solid var(--c-line);
background: #fff;
color: var(--c-ink);
border-radius: 999px;
padding: 6px 14px;
font-weight: 600;
font-size: .9rem;
cursor: pointer;
transition: all .15s ease;
white-space: nowrap;
}
.chip:hover {
border-color: #94a3b8;
}
.chip.active {
background: var(--chip-c, #198754);
border-color: var(--chip-c, #198754);
color: #fff;
}
.chip-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.filter-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 999px;
background: #198754;
color: #fff;
font-size: .75rem;
font-weight: 800;
margin-left: 6px;
}
.search-input {
border-radius: 12px !important;
border: 2px solid #198754 !important;
padding: 9px 14px !important;
}
/* ---------- KPI ---------- */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 14px;
}
@media (min-width: 1200px) {
.kpi-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.kpi {
background: color-mix(in srgb, var(--k) 14%, #fff);
border: 1px solid color-mix(in srgb, var(--k) 28%, #fff);
border-radius: 16px;
padding: 16px 18px;
position: relative;
overflow: hidden;
cursor: pointer;
transition: transform .15s ease, box-shadow .15s ease;
}
.kpi:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(15, 23, 42, .08);
}
.kpi.selected {
background: color-mix(in srgb, var(--k) 24%, #fff);
box-shadow: 0 0 0 2px var(--k, #198754) inset;
}
.kpi-label {
padding-right: 44px;
font-size: .78rem;
font-weight: 800;
color: color-mix(in srgb, var(--k) 70%, #1f2d3d);
text-transform: uppercase;
letter-spacing: .5px;
}
.kpi-value {
font-size: 1.75rem;
font-weight: 800;
color: var(--c-ink);
line-height: 1.15;
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.kpi-sub {
font-size: .85rem;
color: var(--c-muted);
margin-top: 2px;
}
.kpi-icon {
position: absolute;
right: 14px;
top: 14px;
width: 38px;
height: 38px;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
color: var(--k);
font-size: 1.05rem;
box-shadow: 0 2px 6px rgba(15, 23, 42, .06);
}
.delta-up {
color: #198754;
font-weight: 700;
}
.delta-down {
color: #dc3545;
font-weight: 700;
}
/* ---------- GRAFICI ---------- */
.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);
}
.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);
}
.amount {
font-variant-numeric: tabular-nums;
white-space: nowrap;
font-weight: 600;
}
.warn-strip {
background: #fff8e6;
border: 1px solid #ffe2a8;
color: #8a5a00;
border-radius: 12px;
padding: 10px 14px;
font-size: .9rem;
}
/* ---------- TABELLA ---------- */
#ordersTable thead th {
background: #b9ebc7;
color: var(--c-ink);
white-space: nowrap;
}
#ordersTable tbody tr {
cursor: pointer;
}
#ordersTable td {
vertical-align: middle;
}
.st-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 999px;
font-weight: 700;
font-size: .8rem;
color: #fff;
}
.st-aperto {
background: var(--c-open);
}
.st-evaso {
background: var(--c-done);
}
.st-annullato {
background: var(--c-cancel);
}
.st-bloccato {
background: var(--c-block);
}
.late-badge {
background: #fde8ea;
color: var(--c-late);
font-weight: 800;
font-size: .75rem;
padding: 3px 8px;
border-radius: 999px;
margin-left: 6px;
}
.inv-badge {
background: #efe9fb;
color: var(--c-invoice);
font-weight: 700;
font-size: .75rem;
padding: 3px 8px;
border-radius: 999px;
}
.dataTables_wrapper .dt-buttons .btn {
border-radius: 10px;
font-weight: 600;
}
/* ---------- STATI VUOTI / LOADING ---------- */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--c-muted);
}
.empty-state .big {
font-size: 3rem;
}
.loading-overlay {
position: fixed;
inset: 0;
background: rgba(248, 250, 252, .6);
z-index: 2000;
display: none;
align-items: center;
justify-content: center;
backdrop-filter: blur(1px);
}
.loading-overlay.show {
display: flex;
}
/* ---------- MODALE ORDINE ---------- */
.order-head {
background: linear-gradient(135deg, #198754, #3bb77e);
color: #fff;
border-radius: 14px;
padding: 16px 18px;
}
.ro-grid {
display: grid;
grid-template-columns: 200px 1fr;
gap: 8px 16px;
}
.ro-grid .l {
font-weight: 700;
color: #495057;
}
.select2-container--bootstrap-5 .select2-selection {
border-radius: 10px;
min-height: 42px;
}
@media (max-width: 768px) {
.ro-grid {
grid-template-columns: 1fr;
}
}
</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-line"></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>
<div class="d-flex gap-2 flex-wrap">
<?php if ($canSync) : ?>
<div class="btn-group">
<button type="button" class="btn btn-sync" id="btnSyncIncremental">
<i class="fa-solid fa-rotate me-1"></i> Aggiorna da Mago
</button>
<button type="button" class="btn btn-sync dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false" style="border-left:1px solid rgba(255,255,255,.35);">
<span class="visually-hidden">Altre opzioni</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item sync-action" href="#" data-mode="test">🧪 Prova: solo 50 ordini</a></li>
<li><a class="dropdown-item sync-action" href="#" data-mode="full">🔄 Ricarica tutti gli ordini (dal 2020)</a></li>
</ul>
</div>
<?php endif; ?>
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">↩️ Torna alla Dashboard</button>
</div>
</div>
</div>
<div class="card-body pt-0">
<div class="stats-tabs">
<a href="sales_orders_stats.php" class="active"><i class="fa-solid fa-list-check me-1"></i> Andamento ordini</a>
<a href="sales_orders_years.php"><i class="fa-solid fa-chart-column me-1"></i> Confronto anni</a>
</div>
<!-- FILTRI -->
<div class="filters-box mb-4">
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2">
<div class="fw-bold" style="color:var(--c-ink);">
<i class="fa-solid fa-filter me-1"></i> Filtri <span class="filter-count" id="filterCount" style="display:none;">0</span>
</div>
<div class="chip-row" id="periodChips">
<button type="button" class="chip" data-period="month">Mese corrente</button>
<button type="button" class="chip" data-period="prev_month">Mese scorso</button>
<button type="button" class="chip" data-period="30d">Ultimi 30 gg</button>
<button type="button" class="chip" data-period="quarter">Trimestre</button>
<span class="chip-row" id="yearChips"></span>
</div>
</div>
<div class="row g-2 align-items-end">
<div class="col-6 col-md-2">
<label for="fFrom">Dal</label>
<input type="date" id="fFrom" class="form-control">
</div>
<div class="col-6 col-md-2">
<label for="fTo">Al</label>
<input type="date" id="fTo" class="form-control">
</div>
<div class="col-12 col-md-4">
<label for="fCustomers">Clienti</label>
<select id="fCustomers" multiple style="width:100%;"></select>
</div>
<div class="col-6 col-md-2">
<label for="fSalespeople">Agente</label>
<select id="fSalespeople" multiple style="width:100%;"></select>
</div>
<div class="col-6 col-md-2">
<label for="fAreas">Area</label>
<select id="fAreas" multiple style="width:100%;"></select>
</div>
</div>
<div class="row g-2 align-items-end mt-1">
<div class="col-12 col-lg-8">
<label>Stato</label>
<div class="chip-row" id="statusChips">
<button type="button" class="chip" data-status="aperto" style="--chip-c:var(--c-open);">Aperti</button>
<button type="button" class="chip" data-status="ritardo" style="--chip-c:var(--c-late);">In ritardo</button>
<button type="button" class="chip" data-status="bloccato" style="--chip-c:var(--c-block);">Bloccati</button>
<button type="button" class="chip" data-status="evaso" style="--chip-c:var(--c-done);">Evasi</button>
<button type="button" class="chip" data-status="da_fatturare" style="--chip-c:var(--c-invoice);">Da fatturare</button>
<button type="button" class="chip" data-status="fatturato" style="--chip-c:#4c1d95;">Fatturati</button>
<button type="button" class="chip" data-status="annullato" style="--chip-c:var(--c-cancel);">Annullati</button>
</div>
</div>
<div class="col-12 col-lg-4 d-flex gap-2">
<input type="text" id="fSearch" class="form-control search-input" placeholder="🔎 N° ordine, rif. cliente, ragione sociale…">
<button type="button" class="btn btn-outline-secondary" id="btnReset" title="Azzera filtri" style="border-radius:12px;">
<i class="fa-solid fa-eraser"></i>
</button>
</div>
</div>
</div>
<!-- STATO VUOTO -->
<div id="emptyState" class="empty-state" style="display:none;">
<div class="big">📦</div>
<h5 class="mt-2" style="color:var(--c-ink);">Nessun ordine sincronizzato</h5>
<p>Esegui una prima sincronizzazione di prova per importare alcuni ordini da Mago.</p>
<?php if ($canSync) : ?>
<button type="button" class="btn btn-sync sync-action" data-mode="test">🧪 Sincronizza 50 ordini di prova</button>
<?php else : ?>
<p class="small">Chiedi a un amministratore di avviare la sincronizzazione.</p>
<?php endif; ?>
</div>
<div id="statsContent">
<!-- KPI -->
<div class="kpi-grid mb-4" id="kpiGrid">
<div class="kpi" data-status="" style="--k:#198754;">
<div class="kpi-icon"><i class="fa-solid fa-file-invoice"></i></div>
<div class="kpi-label">Ordini</div>
<div class="kpi-value" id="kOrders">—</div>
<div class="kpi-sub" id="kOrdersSub">&nbsp;</div>
</div>
<div class="kpi" data-status="" data-nofilter="1" style="--k:#0f766e;">
<div class="kpi-icon"><i class="fa-solid fa-euro-sign"></i></div>
<div class="kpi-label">Valore ordinato</div>
<div class="kpi-value" id="kValue">—</div>
<div class="kpi-sub" id="kValueSub">&nbsp;</div>
</div>
<div class="kpi" data-status="aperto" style="--k:#1d4ed8;">
<div class="kpi-icon"><i class="fa-solid fa-boxes-stacked"></i></div>
<div class="kpi-label">Da evadere</div>
<div class="kpi-value" id="kOpenValue">—</div>
<div class="kpi-sub" id="kOpenValueSub">&nbsp;</div>
</div>
<div class="kpi" data-status="aperto" style="--k:var(--c-open);">
<div class="kpi-icon"><i class="fa-solid fa-hourglass-half"></i></div>
<div class="kpi-label">Aperti</div>
<div class="kpi-value" id="kOpen">—</div>
<div class="kpi-sub" id="kOpenSub">&nbsp;</div>
</div>
<div class="kpi" data-status="ritardo" style="--k:var(--c-late);">
<div class="kpi-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
<div class="kpi-label">In ritardo</div>
<div class="kpi-value" id="kLate">—</div>
<div class="kpi-sub">consegna prevista superata</div>
</div>
<div class="kpi" data-status="evaso" style="--k:var(--c-done);">
<div class="kpi-icon"><i class="fa-solid fa-truck"></i></div>
<div class="kpi-label">Evasi</div>
<div class="kpi-value" id="kDelivered">—</div>
<div class="kpi-sub" id="kDeliveredSub">&nbsp;</div>
</div>
<div class="kpi" data-status="da_fatturare" style="--k:var(--c-invoice);">
<div class="kpi-icon"><i class="fa-solid fa-receipt"></i></div>
<div class="kpi-label">Da fatturare</div>
<div class="kpi-value" id="kToInvoice">—</div>
<div class="kpi-sub" id="kToInvoiceSub">evasi non fatturati</div>
</div>
<div class="kpi" data-status="" data-nofilter="1" style="--k:#0ea5e9;">
<div class="kpi-icon"><i class="fa-solid fa-users"></i></div>
<div class="kpi-label">Clienti attivi</div>
<div class="kpi-value" id="kCustomers">—</div>
<div class="kpi-sub" id="kLead">&nbsp;</div>
</div>
</div>
<div class="warn-strip mb-3" id="missingAmounts" style="display:none;"></div>
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2">
<div class="chart-title" style="font-size:1.05rem;">Analisi</div>
<div class="metric-switch" id="metricSwitch">
<button type="button" data-metric="count" class="active"># N° ordini</button>
<button type="button" data-metric="value">€ Valore</button>
</div>
</div>
<!-- GRAFICI -->
<div class="row g-3 mb-3">
<div class="col-xl-8">
<div class="chart-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="chart-title" id="ttlMonthly">Andamento mensile ordini</div>
<div class="chart-hint">Clicca un mese per filtrarlo</div>
</div>
</div>
<div id="chMonthly" style="min-height:320px;"></div>
</div>
</div>
<div class="col-xl-4">
<div class="chart-card">
<div class="chart-title">Ripartizione per stato</div>
<div class="chart-hint">Clicca una fetta per filtrare</div>
<div id="chStatus" style="min-height:320px;"></div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-xl-7">
<div class="chart-card">
<div class="chart-title" id="ttlCustomers">Top 10 clienti per numero ordini</div>
<div class="chart-hint">Clicca un cliente per vedere solo i suoi ordini</div>
<div id="chCustomers" style="min-height:360px;"></div>
</div>
</div>
<div class="col-xl-5">
<div class="chart-card">
<div class="chart-title" id="ttlDue">Scadenze ordini aperti</div>
<div class="chart-hint">In base alla data di consegna prevista</div>
<div id="chDue" style="min-height:360px;"></div>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-lg-6">
<div class="chart-card">
<div class="chart-title" id="ttlArea">Ordini per area</div>
<div id="chArea" style="min-height:260px;"></div>
</div>
</div>
<div class="col-lg-6">
<div class="chart-card">
<div class="chart-title" id="ttlSalesperson">Ordini per agente</div>
<div id="chSalesperson" style="min-height:260px;"></div>
</div>
</div>
</div>
<!-- TABELLA -->
<div class="chart-card">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="chart-title">Elenco ordini <span class="text-muted fw-normal" id="rowsInfo"></span></div>
<div class="chart-hint">Clicca una riga per il dettaglio</div>
</div>
<div class="table-responsive">
<table id="ordersTable" class="table table-striped align-middle w-100">
<thead>
<tr>
<th>N° ordine</th>
<th>Data</th>
<th>Cliente</th>
<th>Rif. cliente</th>
<th>Consegna prev.</th>
<th class="text-end">Imponibile</th>
<th>Stato</th>
<th>Fatt.</th>
<th>Agente</th>
<th>Area</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
<!-- MODALE DETTAGLIO ORDINE -->
<div class="modal fade" id="orderModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg modal-dialog-scrollable">
<div class="modal-content" style="border-radius:16px;">
<div class="modal-header" style="background-color:#b9ebc7;">
<h5 class="modal-title">Dettaglio ordine cliente</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="orderModalBody">
<div class="text-muted">Caricamento…</div>
</div>
</div>
</div>
</div>
<div class="loading-overlay" id="loadingOverlay">
<div class="text-center">
<div class="spinner-border text-success" style="width:3rem;height:3rem;" role="status"></div>
<div class="mt-2 fw-semibold" id="loadingText" style="color:var(--c-ink);">Caricamento…</div>
</div>
</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 STATUS_LABEL = {
aperto: 'Aperto',
evaso: 'Evaso',
annullato: 'Annullato',
bloccato: 'Bloccato'
};
const STATUS_COLOR = {
aperto: '#0d6efd',
bloccato: '#fd7e14',
evaso: '#198754',
annullato: '#94a3b8'
};
const MONTHS = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'];
const STORE_KEY = 'zibo_sales_stats_filters_v1';
const charts = {};
let table = null;
let loadTimer = null;
let currentRequest = 0;
let metric = 'count';
/* ---------------- UTILS ---------------- */
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
} [c]));
}
function fmtDate(d) {
if (!d) return '';
const p = String(d).substring(0, 10).split('-');
return p.length === 3 ? `${p[2]}/${p[1]}/${p[0]}` : d;
}
function fmtNum(n) {
return Number(n || 0).toLocaleString('it-IT');
}
function fmtEur(n, decimals) {
return Number(n || 0).toLocaleString('it-IT', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: decimals === undefined ? 0 : decimals,
maximumFractionDigits: decimals === undefined ? 0 : decimals
});
}
// compatto per assi e KPI grandi: 1.234.567 → 1,23 M€
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);
}
// formatter in base alla metrica scelta
function fmtMetric(v) {
return metric === 'value' ? fmtEur(v) : fmtNum(Math.round(v));
}
function fmtMetricAxis(v) {
return metric === 'value' ? fmtEurShort(v) : fmtNum(Math.round(v));
}
function deltaHtml(cur, prev, from, to) {
if (prev === null || prev === undefined) return '';
if (+prev === 0) return `<span title="${esc(from)} – ${esc(to)}">anno prec.: nessun dato</span>`;
const d = Math.round((cur - prev) * 100 / prev);
return `<span class="${d >= 0 ? 'delta-up' : 'delta-down'}">${d >= 0 ? '▲' : '▼'} ${Math.abs(d)}%</span> <span title="${esc(from)} – ${esc(to)}">vs stesso periodo anno prec.</span>`;
}
function iso(d) {
const z = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`;
}
function ymLabel(ym) {
const [y, m] = ym.split('-');
return `${MONTHS[parseInt(m, 10) - 1]} ${y.substring(2)}`;
}
function showLoading(text) {
$('#loadingText').text(text || 'Caricamento…');
$('#loadingOverlay').addClass('show');
}
function hideLoading() {
$('#loadingOverlay').removeClass('show');
}
/* ---------------- FILTRI ---------------- */
function periodRange(p) {
const t = new Date();
const y = t.getFullYear(),
m = t.getMonth();
switch (p) {
case 'month':
return [new Date(y, m, 1), t];
case 'prev_month':
return [new Date(y, m - 1, 1), new Date(y, m, 0)];
case '30d':
return [new Date(t.getTime() - 29 * 86400000), t];
case 'quarter':
return [new Date(y, Math.floor(m / 3) * 3, 1), t];
default: {
// 'y2025' = anno intero (l'anno in corso fino a oggi)
const yy = /^y\d{4}$/.test(p) ? parseInt(p.substring(1), 10) : y;
return [new Date(yy, 0, 1), yy === y ? t : new Date(yy, 11, 31)];
}
}
}
function setPeriod(p, silent) {
const [a, b] = periodRange(p);
$('#fFrom').val(iso(a));
$('#fTo').val(iso(b));
$('#periodChips .chip').removeClass('active');
$(`#periodChips .chip[data-period="${p}"]`).addClass('active');
if (!silent) scheduleLoad(0);
}
function getFilters() {
return {
from: $('#fFrom').val(),
to: $('#fTo').val(),
customers: $('#fCustomers').val() || [],
salespeople: $('#fSalespeople').val() || [],
areas: $('#fAreas').val() || [],
statuses: $('#statusChips .chip.active').map((i, el) => $(el).data('status')).get(),
q: $('#fSearch').val().trim()
};
}
function toQuery(f) {
const p = new URLSearchParams();
p.append('action', 'data');
p.append('metric', metric);
['from', 'to', 'q'].forEach(k => {
if (f[k]) p.append(k, f[k]);
});
['customers', 'salespeople', 'areas', 'statuses'].forEach(k => (f[k] || []).forEach(v => p.append(k + '[]', v)));
return p.toString();
}
function updateFilterCount(f) {
const n = f.customers.length + f.salespeople.length + f.areas.length + f.statuses.length + (f.q ? 1 : 0);
$('#filterCount').text(n).toggle(n > 0);
// evidenzio il KPI corrispondente allo stato filtrato
$('.kpi').removeClass('selected');
if (f.statuses.length === 1) $(`.kpi[data-status="${f.statuses[0]}"]`).addClass('selected');
}
function saveFilters(f) {
try {
localStorage.setItem(STORE_KEY, JSON.stringify(Object.assign({}, f, {
metric: metric
})));
} catch (e) {}
}
function restoreFilters() {
try {
return JSON.parse(localStorage.getItem(STORE_KEY) || 'null');
} catch (e) {
return null;
}
}
function scheduleLoad(delay) {
clearTimeout(loadTimer);
loadTimer = setTimeout(loadData, delay === undefined ? 350 : delay);
}
function toggleStatus(status, exclusive) {
const $chip = $(`#statusChips .chip[data-status="${status}"]`);
if (exclusive) {
const wasOnlyActive = $chip.hasClass('active') && $('#statusChips .chip.active').length === 1;
$('#statusChips .chip').removeClass('active');
if (!wasOnlyActive) $chip.addClass('active');
} else {
$chip.toggleClass('active');
}
scheduleLoad(0);
}
/* ---------------- CARICAMENTO OPZIONI ---------------- */
function initSelect(id, placeholder) {
$(id).select2({
theme: 'bootstrap-5',
placeholder: placeholder,
allowClear: true,
closeOnSelect: false,
width: '100%'
}).on('change', () => scheduleLoad());
}
function renderSyncInfo(s) {
const $pill = $('#syncPill').removeClass('ok err');
if (!s || s.error) {
$pill.addClass('err');
$('#syncText').text(s && s.error ? s.error : 'Stato sincronizzazione non disponibile');
return;
}
const o = s.sale_ord || {};
if (!o.last_run_at) {
$('#syncText').text('Mai sincronizzato');
return;
}
$pill.addClass(o.last_status === 'ok' ? 'ok' : 'err');
$('#syncText').text(`Aggiornato il ${o.last_run_at} · ${fmtNum(s.total_orders)} ordini in archivio`);
$pill.attr('title', o.last_message || '');
}
function loadFilterOptions(keep) {
return fetch('?action=filters')
.then(r => r.json())
.then(d => {
renderSyncInfo(d.sync);
if (!d.success) return d;
const fill = (id, list, labelFn) => {
const $s = $(id);
const prev = keep ? ($s.val() || []) : [];
$s.empty();
list.forEach(o => $s.append(new Option(labelFn(o), o.code, false, false)));
$s.val(prev).trigger('change.select2');
};
const curY = new Date().getFullYear();
const years = (d.years && d.years.length ? d.years : [curY]).slice().sort((a, b) => b - a);
if (!years.includes(curY)) years.unshift(curY);
const activeP = $('#periodChips .chip.active').data('period');
$('#yearChips').html(years.map(yy => `<button type="button" class="chip" data-period="y${yy}">${yy}</button>`).join(''));
if (activeP) $(`#periodChips .chip[data-period="${activeP}"]`).addClass('active');
fill('#fCustomers', d.customers || [], o => `${o.name} (${o.code}) · ${o.n}`);
fill('#fSalespeople', d.salespeople || [], o => `${o.code} · ${o.n}`);
fill('#fAreas', d.areas || [], o => `${o.code} · ${o.n}`);
return d;
});
}
/* ---------------- DATI ---------------- */
function loadData() {
const f = getFilters();
updateFilterCount(f);
saveFilters(f);
const reqId = ++currentRequest;
$('#statsContent').css('opacity', .55);
fetch('?' + toQuery(f))
.then(r => r.json())
.then(d => {
if (reqId !== currentRequest) return; // risposta superata da una richiesta più recente
$('#statsContent').css('opacity', 1);
if (!d.success) {
Swal.fire({
icon: 'error',
title: 'Errore',
text: d.message || 'Errore nel caricamento dati'
});
return;
}
updateMetricTitles();
renderKpi(d.kpi);
renderMonthly(d.monthly);
renderStatus(d.kpi);
renderCustomers(d.top_customers);
renderDue(d.due_buckets);
renderSimpleBar('chArea', d.by_area, '#0ea5e9', 'areas');
renderSimpleBar('chSalesperson', d.by_salesperson, '#6f42c1', 'salespeople');
renderTable(d.rows);
})
.catch(err => {
$('#statsContent').css('opacity', 1);
console.error(err);
});
}
function updateMetricTitles() {
const v = metric === 'value';
$('#metricSwitch button').removeClass('active');
$(`#metricSwitch button[data-metric="${metric}"]`).addClass('active');
$('#ttlMonthly').text(v ? 'Andamento mensile valore ordinato (imponibile)' : 'Andamento mensile ordini');
$('#ttlCustomers').text(v ? 'Top 10 clienti per valore ordinato' : 'Top 10 clienti per numero ordini');
$('#ttlDue').text(v ? 'Scadenze ordini aperti (valore)' : 'Scadenze ordini aperti');
$('#ttlArea').text(v ? 'Valore per area' : 'Ordini per area');
$('#ttlSalesperson').text(v ? 'Valore per agente' : 'Ordini per agente');
}
function renderKpi(k) {
const orders = +k.orders || 0;
$('#kOrders').text(fmtNum(orders));
$('#kOpen').text(fmtNum(k.open_orders));
$('#kLate').text(fmtNum(k.late));
$('#kDelivered').text(fmtNum(k.delivered));
$('#kToInvoice').text(fmtNum(k.to_invoice));
$('#kCustomers').text(fmtNum(k.customers));
$('#kValue').text(fmtEurShort(k.value_total)).attr('title', fmtEur(k.value_total, 2));
$('#kValueSub').html(deltaHtml(+k.value_total, k.prev_value, k.prev_from, k.prev_to) ||
(k.value_avg ? `medio ${fmtEur(k.value_avg)} per ordine` : '&nbsp;'));
$('#kOpenValue').text(fmtEurShort(k.value_open)).attr('title', fmtEur(k.value_open, 2));
$('#kOpenValueSub').html(+k.value_late > 0 ?
`<span class="delta-down">${fmtEurShort(k.value_late)}</span> in ritardo` : 'nessun importo in ritardo');
$('#kToInvoiceSub').html(+k.value_to_invoice > 0 ? `evasi non fatturati · ${fmtEurShort(k.value_to_invoice)}` : 'evasi non fatturati');
const miss = +k.missing_amounts || 0;
$('#missingAmounts').toggle(miss > 0).html(miss > 0 ?
`<i class="fa-solid fa-circle-exclamation me-1"></i> ${fmtNum(miss)} ordini senza importi: premi <b>Aggiorna da Mago</b> per leggere i totali.` : '');
const pct = n => orders ? Math.round((+n || 0) * 100 / orders) + '% del totale' : '&nbsp;';
$('#kOpenSub').html(pct(k.open_orders) + (+k.blocked ? ` · ${fmtNum(k.blocked)} bloccati` : ''));
$('#kDeliveredSub').html(pct(k.delivered));
$('#kLead').html(k.avg_lead_days !== null && k.avg_lead_days !== undefined ?
`consegna media a <b>${Math.round(k.avg_lead_days)}</b> gg dall'ordine` : '&nbsp;');
if (k.prev_orders === null || k.prev_orders === undefined) {
$('#kOrdersSub').html('&nbsp;');
} else if (+k.prev_orders === 0) {
$('#kOrdersSub').html(`<span title="${esc(k.prev_from)} – ${esc(k.prev_to)}">anno prec.: nessun dato</span>`);
} else {
const delta = Math.round((orders - k.prev_orders) * 100 / k.prev_orders);
const cls = delta >= 0 ? 'delta-up' : 'delta-down';
const arrow = delta >= 0 ? '▲' : '▼';
$('#kOrdersSub').html(`<span class="${cls}">${arrow} ${Math.abs(delta)}%</span> vs ${fmtNum(k.prev_orders)} <span title="${esc(k.prev_from)} – ${esc(k.prev_to)}">stesso periodo anno prec.</span>`);
}
const empty = orders === 0 && !$('#filterCount').is(':visible');
$('#emptyState').toggle(false);
if (empty && window.__totalOrders === 0) {
$('#emptyState').show();
$('#statsContent').hide();
} else {
$('#statsContent').show();
}
}
function upsertChart(key, el, options) {
if (charts[key]) {
charts[key].updateOptions(options, true, true);
} else {
charts[key] = new ApexCharts(document.getElementById(el), options);
charts[key].render();
}
}
const baseChart = {
fontFamily: 'inherit',
toolbar: {
show: false
},
animations: {
speed: 350
}
};
function renderMonthly(rows) {
const cats = rows.map(r => ymLabel(r.ym));
upsertChart('monthly', 'chMonthly', {
chart: {
...baseChart,
type: 'bar',
height: 320,
stacked: true,
events: {
dataPointSelection: (e, ctx, cfg) => {
const ym = rows[cfg.dataPointIndex] && rows[cfg.dataPointIndex].ym;
if (!ym) return;
const [y, m] = ym.split('-').map(Number);
$('#fFrom').val(iso(new Date(y, m - 1, 1)));
$('#fTo').val(iso(new Date(y, m, 0)));
$('#periodChips .chip').removeClass('active');
scheduleLoad(0);
}
}
},
series: [{
name: 'Aperti',
data: rows.map(r => +r.aperto)
},
{
name: 'Bloccati',
data: rows.map(r => +r.bloccato)
},
{
name: 'Evasi',
data: rows.map(r => +r.evaso)
},
...(metric === 'value' ? [] : [{
name: 'Annullati',
data: rows.map(r => +r.annullato)
}])
],
colors: [STATUS_COLOR.aperto, STATUS_COLOR.bloccato, STATUS_COLOR.evaso, STATUS_COLOR.annullato],
xaxis: {
categories: cats
},
yaxis: {
labels: {
formatter: fmtMetricAxis
}
},
plotOptions: {
bar: {
borderRadius: 4,
columnWidth: '55%'
}
},
dataLabels: {
enabled: false
},
legend: {
position: 'top',
horizontalAlign: 'left'
},
grid: {
borderColor: '#eef2f7'
},
noData: {
text: 'Nessun dato nel periodo'
},
tooltip: {
shared: true,
intersect: false,
y: {
formatter: fmtMetric
}
}
});
}
function renderStatus(k) {
const keys = ['aperto', 'bloccato', 'evaso', 'annullato'];
const vals = [+k.open_orders || 0, +k.blocked || 0, +k.delivered || 0, +k.cancelled || 0];
upsertChart('status', 'chStatus', {
chart: {
...baseChart,
type: 'donut',
height: 320,
events: {
dataPointSelection: (e, ctx, cfg) => toggleStatus(keys[cfg.dataPointIndex], true)
}
},
series: vals,
labels: keys.map(s => STATUS_LABEL[s]),
colors: keys.map(s => STATUS_COLOR[s]),
legend: {
position: 'bottom'
},
dataLabels: {
formatter: v => Math.round(v) + '%'
},
plotOptions: {
pie: {
donut: {
size: '62%',
labels: {
show: true,
total: {
show: true,
label: 'Ordini',
formatter: w => fmtNum(w.globals.seriesTotals.reduce((a, b) => a + b, 0))
}
}
}
}
},
noData: {
text: 'Nessun dato'
}
});
}
function renderCustomers(rows) {
upsertChart('customers', 'chCustomers', {
chart: {
...baseChart,
type: 'bar',
height: 360,
stacked: true,
events: {
dataPointSelection: (e, ctx, cfg) => {
const r = rows[cfg.dataPointIndex];
if (!r) return;
$('#fCustomers').val([r.code]).trigger('change');
}
}
},
series: [{
name: 'Aperti',
data: rows.map(r => +r.open_n)
},
{
name: 'Altri',
data: rows.map(r => (+r.n) - (+r.open_n))
}
],
colors: [STATUS_COLOR.aperto, '#9fd8b6'],
xaxis: {
categories: rows.map(r => r.name && r.name.length > 34 ? r.name.substring(0, 32) + '…' : (r.name || r.code)),
labels: {
formatter: fmtMetricAxis
}
},
plotOptions: {
bar: {
horizontal: true,
borderRadius: 4,
barHeight: '68%'
}
},
dataLabels: {
enabled: false
},
legend: {
position: 'top',
horizontalAlign: 'left'
},
grid: {
borderColor: '#eef2f7'
},
tooltip: {
shared: true,
intersect: false,
y: {
formatter: fmtMetric
}
},
noData: {
text: 'Nessun dato'
}
});
}
function renderDue(rows) {
const order = [
['1_scaduti', 'Scaduti', '#dc3545'],
['2_settimana', 'Questa settimana', '#fd7e14'],
['3_prossima', 'Prossima settimana', '#ffc107'],
['4_30gg', 'Entro 30 gg', '#0d6efd'],
['5_oltre', 'Oltre 30 gg', '#64748b'],
['6_senza', 'Senza data', '#cbd5e1']
];
const map = {};
rows.forEach(r => map[r.bucket] = +r.n);
upsertChart('due', 'chDue', {
chart: {
...baseChart,
type: 'bar',
height: 360
},
series: [{
name: metric === 'value' ? 'Valore aperti' : 'Ordini aperti',
data: order.map(o => map[o[0]] || 0)
}],
colors: order.map(o => o[2]),
xaxis: {
categories: order.map(o => o[1])
},
plotOptions: {
bar: {
distributed: true,
borderRadius: 6,
columnWidth: '60%'
}
},
legend: {
show: false
},
dataLabels: {
enabled: true,
formatter: fmtMetricAxis,
style: {
colors: ['#1f2d3d']
},
offsetY: -18
},
tooltip: {
y: {
formatter: fmtMetric
}
},
yaxis: {
labels: {
formatter: fmtMetricAxis
}
},
grid: {
borderColor: '#eef2f7'
}
});
}
function renderSimpleBar(el, rows, color, filterKey) {
const selectId = {
areas: '#fAreas',
salespeople: '#fSalespeople'
} [filterKey];
upsertChart(el, el, {
chart: {
...baseChart,
type: 'bar',
height: 260,
events: {
dataPointSelection: (e, ctx, cfg) => {
const r = rows[cfg.dataPointIndex];
if (!r || r.code === '') return;
$(selectId).val([r.code]).trigger('change');
}
}
},
series: [{
name: metric === 'value' ? 'Valore' : 'Ordini',
data: rows.map(r => +r.n)
}],
colors: [color],
tooltip: {
y: {
formatter: fmtMetric
}
},
xaxis: {
categories: rows.map(r => r.label)
},
plotOptions: {
bar: {
borderRadius: 5,
columnWidth: '55%'
}
},
dataLabels: {
enabled: false
},
yaxis: {
labels: {
formatter: fmtMetricAxis
}
},
grid: {
borderColor: '#eef2f7'
},
noData: {
text: 'Nessun dato'
}
});
}
function renderTable(rows) {
const data = rows.map(r => [
`<b>${esc(r.internal_ord_no)}</b>${r.external_ord_no ? `<div class="small text-muted">${esc(r.external_ord_no)}</div>` : ''}`,
`<span data-order="${esc(r.order_date || '')}">${fmtDate(r.order_date)}</span>`,
`<div class="fw-semibold">${esc(r.company_name)}</div><div class="small text-muted">${esc(r.customer)}</div>`,
esc(r.your_reference),
`<span data-order="${esc(r.due_date || '')}">${fmtDate(r.due_date) || '—'}</span>`,
+r.no_amount ? '<span data-order="-1" class="text-muted">n.d.</span>' :
`<span data-order="${+r.taxable_amount}" class="amount${r.status === 'annullato' ? ' text-muted text-decoration-line-through' : ''}">${fmtEur(r.taxable_amount, 2)}</span>`,
`<span class="st-badge st-${esc(r.status)}">${STATUS_LABEL[r.status] || esc(r.status)}</span>${+r.is_late ? '<span class="late-badge">IN RITARDO</span>' : ''}`,
+r.invoiced ? '<span class="inv-badge">Sì</span>' : '<span class="text-muted">—</span>',
esc(r.salesperson),
esc(r.area),
r.sale_ord_id
]);
$('#rowsInfo').text(rows.length >= 5000 ? '(prime 5.000 righe: restringi i filtri)' : `(${fmtNum(rows.length)})`);
if (table) {
table.clear().rows.add(data).draw(false);
return;
}
table = $('#ordersTable').DataTable({
data: data,
columns: [{}, {}, {}, {}, {}, {
className: 'text-end'
}, {}, {}, {}, {}, {
visible: false,
searchable: false
}],
columnDefs: [{
targets: [1, 4],
type: 'string',
render: (d, type) => type === 'sort' || type === 'type' ? ($(d).data('order') || '') : d
}, {
targets: [5],
type: 'num',
render: (d, type) => type === 'sort' || type === 'type' ? (+$(d).data('order') || 0) : d
}],
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: 'Ordini cliente',
exportOptions: {
columns: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
format: {
// importo esportato come numero, il resto come testo
body: (d, row, col) => col === 5 ? (+$(d).data('order') >= 0 ? +$(d).data('order') : '') : $('<div>').html(String(d).replace(/<(div|span)/g, ' <$1')).text().replace(/\s+/g, ' ').trim()
}
}
},
{
extend: 'csvHtml5',
text: '<i class="fa-solid fa-file-csv me-1"></i> CSV',
className: 'btn btn-outline-secondary btn-sm',
fieldSeparator: ';',
exportOptions: {
columns: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
format: {
// importo esportato come numero, il resto come testo
body: (d, row, col) => col === 5 ? (+$(d).data('order') >= 0 ? +$(d).data('order') : '') : $('<div>').html(String(d).replace(/<(div|span)/g, ' <$1')).text().replace(/\s+/g, ' ').trim()
}
}
}
]
},
language: {
url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json'
}
});
$('#ordersTable tbody').on('click', 'tr', function() {
const row = table.row(this).data();
if (row) openOrder(row[10]);
});
}
/* ---------------- DETTAGLIO ORDINE ---------------- */
function openOrder(id) {
$('#orderModalBody').html('<div class="text-muted">Caricamento…</div>');
bootstrap.Modal.getOrCreateInstance(document.getElementById('orderModal')).show();
fetch('?action=order&id=' + encodeURIComponent(id))
.then(r => r.json())
.then(d => {
if (!d.success) {
$('#orderModalBody').html(`<div class="text-danger">${esc(d.message)}</div>`);
return;
}
const o = d.order;
const extra = Object.entries(d.extra || {})
.filter(([k, v]) => v !== null && v !== '')
.map(([k, v]) => `<div class="l">${esc(k)}</div><div>${/^\d{4}-\d{2}-\d{2}/.test(v) ? esc(fmtDate(v) + (v.length > 10 ? ' ' + v.substring(11, 16) : '')) : esc(v)}</div>`)
.join('');
$('#orderModalBody').html(`
<div class="order-head mb-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
<div>
<h4 class="mb-1">Ordine ${esc(o.internal_ord_no)}</h4>
<div>${esc(o.company_name)} <span style="opacity:.8;">(${esc(o.customer)})</span></div>
</div>
<div class="text-end">
<span class="st-badge st-${esc(o.status)}" style="border:2px solid rgba(255,255,255,.7);">${STATUS_LABEL[o.status] || esc(o.status)}</span>
</div>
</div>
</div>
${o.summary_tb_modified ? `
<div class="row g-2 mb-3 text-center">
<div class="col-6 col-md-3"><div class="p-2 rounded" style="background:#ecfdf5;"><div class="small text-muted">Imponibile</div><div class="fw-bold amount">${fmtEur(o.taxable_amount, 2)}</div></div></div>
<div class="col-6 col-md-3"><div class="p-2 rounded" style="background:#f8fafc;"><div class="small text-muted">IVA</div><div class="fw-bold amount">${fmtEur(o.tax_amount, 2)}</div></div></div>
<div class="col-6 col-md-3"><div class="p-2 rounded" style="background:#eff6ff;"><div class="small text-muted">Totale</div><div class="fw-bold amount">${fmtEur(o.total_amount, 2)}</div></div></div>
<div class="col-6 col-md-3"><div class="p-2 rounded" style="background:#f8fafc;"><div class="small text-muted">Sconti / Spese</div><div class="fw-bold amount">${fmtEur(o.discounts, 2)} / ${fmtEur(o.charges, 2)}</div></div></div>
</div>` : '<div class="warn-strip mb-3">Importi non ancora letti da Mago.</div>'}
<div class="ro-grid mb-3">
<div class="l">Data ordine</div><div>${fmtDate(o.order_date) || '—'}</div>
<div class="l">Consegna prevista</div><div>${fmtDate(o.expected_delivery_date) || '—'}</div>
<div class="l">Consegna confermata</div><div>${fmtDate(o.confirmed_delivery_date) || '—'}</div>
<div class="l">N° ordine cliente</div><div>${esc(o.external_ord_no) || '—'}</div>
<div class="l">Agente</div><div>${esc(o.salesperson) || '—'}</div>
<div class="l">Area</div><div>${esc(o.area) || '—'}</div>
${extra}
</div>
${o.notes ? `<div class="p-3 rounded" style="background:#f8fafc;border:1px solid #e2e8f0;white-space:pre-wrap;"><b>Note:</b><br>${esc(o.notes)}</div>` : ''}
<div class="mt-3 small text-muted"><i class="fa-solid fa-circle-info me-1"></i>Le righe articolo non sono disponibili: la tabella righe ordine non è esposta dalle API di Mago.</div>
`);
});
}
/* ---------------- SINCRONIZZAZIONE ---------------- */
function runSync(mode) {
const labels = {
incremental: 'Aggiornamento da Mago in corso…',
test: 'Sincronizzazione di prova (50 ordini)…',
full: 'Ricarico tutti gli ordini da Mago…'
};
showLoading(labels[mode]);
const body = new URLSearchParams({
action: 'sync',
mode: mode,
limit: 50
});
fetch('', {
method: 'POST',
body: body
})
.then(r => r.json())
.then(d => {
hideLoading();
if (!d.ok) {
Swal.fire({
icon: 'error',
title: 'Sincronizzazione non riuscita',
text: d.message || ''
});
if (d.info) renderSyncInfo(d.info);
return;
}
const o = d.ordini || {},
c = d.clienti || {};
window.__totalOrders = d.info ? d.info.total_orders : window.__totalOrders;
Swal.fire({
icon: 'success',
title: 'Dati aggiornati',
html: `Ordini letti: <b>${fmtNum(o.records)}</b>` +
(d.importi ? `<br>Totali letti: <b>${fmtNum(d.importi.records)}</b>` : '') +
(c.records !== undefined ? `<br>Clienti aggiornati: <b>${fmtNum(c.records)}</b>` : '') +
(d.logoff !== 'OK' ? `<div class="small text-muted mt-2">Logoff Mago: ${esc(d.logoff)}</div>` : ''),
timer: 3500
});
loadFilterOptions(true).then(() => loadData());
})
.catch(() => {
hideLoading();
Swal.fire({
icon: 'error',
title: 'Errore',
text: 'Errore di comunicazione con il server (la sincronizzazione potrebbe essere ancora in corso).'
});
});
}
/* ---------------- EVENTI ---------------- */
$(function() {
initSelect('#fCustomers', 'Tutti i clienti');
initSelect('#fSalespeople', 'Tutti');
initSelect('#fAreas', 'Tutte');
$('#periodChips').on('click', '.chip', function() {
setPeriod($(this).data('period'));
});
$('#fFrom, #fTo').on('change', function() {
$('#periodChips .chip').removeClass('active');
scheduleLoad(0);
});
$('#statusChips').on('click', '.chip', function() {
toggleStatus($(this).data('status'), false);
});
$('#fSearch').on('input', () => scheduleLoad(450));
$('#kpiGrid').on('click', '.kpi', function() {
if ($(this).data('nofilter')) return;
const st = $(this).data('status');
if (!st) {
$('#statusChips .chip').removeClass('active');
scheduleLoad(0);
return;
}
toggleStatus(st, true);
});
$('#btnReset').on('click', function() {
$('#fCustomers, #fSalespeople, #fAreas').val(null).trigger('change.select2');
$('#statusChips .chip').removeClass('active');
$('#fSearch').val('');
setPeriod('y' + new Date().getFullYear());
});
$('#metricSwitch').on('click', 'button', function() {
metric = $(this).data('metric');
updateMetricTitles();
scheduleLoad(0);
});
$('#btnSyncIncremental').on('click', () => runSync('incremental'));
$(document).on('click', '.sync-action', function(e) {
e.preventDefault();
const mode = $(this).data('mode');
if (mode === 'full') {
Swal.fire({
icon: 'question',
title: 'Ricaricare tutti gli ordini dal 2020?',
text: 'Può richiedere alcuni minuti: per il primo caricamento è meglio usare il comando da terminale.',
showCancelButton: true,
confirmButtonText: 'Sì, ricarica',
cancelButtonText: 'Annulla'
}).then(r => {
if (r.isConfirmed) runSync('full');
});
return;
}
runSync(mode);
});
// Avvio: opzioni filtri → ripristino ultimi filtri → dati
loadFilterOptions(false).then(d => {
window.__totalOrders = d && d.sync ? d.sync.total_orders : 0;
const saved = restoreFilters();
if (saved && saved.metric === 'value') metric = 'value';
if (saved && saved.from) {
$('#fFrom').val(saved.from);
$('#fTo').val(saved.to);
$('#fCustomers').val(saved.customers || []).trigger('change.select2');
$('#fSalespeople').val(saved.salespeople || []).trigger('change.select2');
$('#fAreas').val(saved.areas || []).trigger('change.select2');
(saved.statuses || []).forEach(s => $(`#statusChips .chip[data-status="${s}"]`).addClass('active'));
$('#fSearch').val(saved.q || '');
} else {
setPeriod('y' + new Date().getFullYear(), true);
}
loadData();
});
});
})();
</script>
</body>
</html>