Files
2026-10-28 13:52:47 +01:00

2899 lines
128 KiB
PHP
Raw Permalink 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';
require_once __DIR__ . '/class/UiPrefs.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.is_late) AS late,
SUM(x.is_late_client) AS late_client,
SUM(x.status IN ('aperto','bloccato') AND x.late_lines > 0 AND x.late_lines < x.open_lines) AS late_partial,
SUM(CASE WHEN x.status IN ('aperto','bloccato') THEN x.late_lines ELSE 0 END) AS late_lines,
SUM(x.status IN ('aperto','bloccato') AND x.week_lines > 0) AS week_orders,
SUM(CASE WHEN x.status IN ('aperto','bloccato') THEN x.week_lines ELSE 0 END) AS week_lines,
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.is_late = 1 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];
// --- Da evadere per anno della data ZIBO: residuo delle righe ancora aperte (ritardi = anno in corso) ---
$cy = (int)date('Y');
$zy = "YEAR(" . MagoSalesStats::ziboDate('d') . ")";
$rv = "CASE WHEN d.qty > 0 THEN d.taxable_amount * GREATEST(d.qty - d.delivered_qty, 0) / d.qty ELSE d.taxable_amount END";
$kpi['toship'] = MagoSalesStats::fetchAllQ($pdo, "
SELECT COALESCE(SUM(CASE WHEN $zy <= $cy THEN $rv END), 0) AS y_cur,
COALESCE(SUM(CASE WHEN $zy = $cy + 1 THEN $rv END), 0) AS y_next,
COALESCE(SUM(CASE WHEN $zy > $cy + 1 THEN $rv END), 0) AS y_later,
COALESCE(SUM(CASE WHEN $zy IS NULL THEN $rv END), 0) AS y_nodate
FROM mago_sale_ord_detail d
JOIN $from ON x.sale_ord_id = d.sale_ord_id
WHERE $where AND x.status IN ('aperto','bloccato')
AND d.delivered = 0 AND d.cancelled = 0 AND (d.item <> '' OR d.qty <> 0)
", $params)[0];
$kpi['toship']['year'] = $cy;
// --- 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.is_late = 1 THEN '1_scaduti'
WHEN x.open_due IS NULL THEN '6_senza'
WHEN YEARWEEK(x.open_due, 3) = YEARWEEK(CURDATE(), 3) THEN '2_settimana'
WHEN YEARWEEK(x.open_due, 3) = YEARWEEK(CURDATE() + INTERVAL 7 DAY, 3) THEN '3_prossima'
WHEN x.open_due <= 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);
// --- Top articoli (dalle righe, annullati esclusi) ---
$topItems = MagoSalesStats::fetchAllQ($pdo, "
SELECT d.item AS code,
MAX(d.description) AS name,
MAX(d.uom) AS uom,
SUM(d.qty) AS qty,
SUM(d.taxable_amount) AS value,
COUNT(DISTINCT d.sale_ord_id) AS orders
FROM mago_sale_ord_detail d
JOIN $from ON x.sale_ord_id = d.sale_ord_id
WHERE $where AND d.item <> '' AND d.cancelled = 0 AND x.status <> 'annullato'
GROUP BY d.item
ORDER BY " . ($metric === 'value' ? 'value' : 'orders') . " DESC
LIMIT 10
", $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.zibo_date, x.client_date, x.open_lines, x.late_lines, x.late_lines_client, x.open_dates, x.week_lines,
x.is_late_client,
(x.summary_tb_modified IS NULL) AS no_amount,
x.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,
'top_items' => $topItems,
'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']);
// righe + (se presenti) matrice, coda "Nuovi da Mago" e lavoro di produzione collegato
$has = function ($t) use ($pdo) {
$q = $pdo->prepare("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?");
$q->execute([$t]);
return (bool)$q->fetchColumn();
};
$colQ = $pdo->prepare("SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mago_sale_ord_detail' AND COLUMN_NAME = 'matrice_misura'");
$colQ->execute();
$hasMM = (bool)$colQ->fetchColumn();
$hasQ = $hasMM && $has('mago_prod_queue') && $has('productiondata');
$sel = "d.sub_id, d.line, d.item, d.description, d.uom, d.qty, d.delivered_qty, d.invoiced_qty, d.unit_value,
GREATEST(d.qty - d.delivered_qty, 0) AS residual_qty,
d.taxable_amount, d.delivered, d.invoiced, d.cancelled, d.notes,
" . MagoSalesStats::ziboDate('d') . " AS zibo_date,
" . MagoSalesStats::clientDate('d') . " AS client_date";
$join = '';
if ($hasMM) $sel .= ", d.matrice_misura, d.cod_art_cli";
if ($hasQ) {
$sel .= ", q.queue_status, q.match_status, q.match_info, q.discard_reason, q.mago_changed, q.mago_changes,
q.productiondata_id, COALESCE(p.idmatrice, q.idmatrice) AS idmatrice,
m.nome AS matrice, m.photo AS matrice_photo,
p.id_status AS prod_status, s.nome AS prod_status_name, COALESCE(s.badge_color, '#6c757d') AS prod_status_color,
p.id_linea, l.name AS linea, l.color AS linea_color, p.metri AS prod_metri, p.data_produzione,
(SELECT GROUP_CONCAT(ms.nome ORDER BY ms.nome SEPARATOR ', ') FROM productiondata_mescole pm
JOIN mescole ms ON ms.id = pm.id_mescola WHERE pm.id_productiondata = p.id) AS mescole";
$join = "LEFT JOIN mago_prod_queue q ON q.sale_ord_id = d.sale_ord_id AND q.sub_id = d.sub_id
LEFT JOIN productiondata p ON p.id = q.productiondata_id
LEFT JOIN matrice m ON m.id = COALESCE(p.idmatrice, q.idmatrice)
LEFT JOIN production_status s ON s.id = p.id_status
LEFT JOIN production_lines l ON l.id = p.id_linea";
}
$stL = $pdo->prepare("SELECT $sel FROM mago_sale_ord_detail d $join WHERE d.sale_ord_id = ? ORDER BY d.position, d.line, d.sub_id");
$stL->execute([$id]);
$lines = $stL->fetchAll(PDO::FETCH_ASSOC);
// righe non (ancora) in coda: provo comunque a riconoscere la matrice dal valore Mago
if ($hasMM) {
if (!class_exists('MagoProduction') && is_file(__DIR__ . '/class/MagoProduction.php')) require_once __DIR__ . '/class/MagoProduction.php';
foreach ($lines as &$l) {
$l['mm'] = null;
if (!class_exists('MagoProduction') || trim((string)$l['matrice_misura']) === '') continue;
$l['mm'] = MagoProduction::parse($l['matrice_misura']);
$l['mm']['tipo_label'] = MagoProduction::TIPI[$l['mm']['tipo']] ?? '';
if (empty($l['idmatrice'])) {
[$idm, $ms, $mi] = MagoProduction::lookup($pdo, $l['matrice_misura']);
if ($idm) {
$mt = $pdo->prepare("SELECT nome, photo FROM matrice WHERE id = ?");
$mt->execute([$idm]);
$r = $mt->fetch(PDO::FETCH_ASSOC) ?: [];
$l['idmatrice'] = $idm;
$l['matrice'] = $r['nome'] ?? '';
$l['matrice_photo'] = $r['photo'] ?? '';
}
if (empty($l['match_status'])) {
$l['match_status'] = $ms;
$l['match_info'] = $mi;
}
}
}
unset($l);
// linee e mescole associate alla matrice in anagrafica (scheda di lavoro attiva, altrimenti matrici_mescole)
if ($has('matrici_lines') && class_exists('MagoProduction')) {
$lq = $pdo->prepare("SELECT l.name, l.color FROM matrici_lines ml JOIN production_lines l ON l.id = ml.idlinea WHERE ml.idmatrice = ? ORDER BY l.line_number, l.id");
$mq = $pdo->prepare("SELECT nome FROM mescole WHERE id = ?");
$cache = [];
foreach ($lines as &$l) {
$l['matrice_lines'] = [];
$l['matrice_mescole'] = [];
$idm = (int)($l['idmatrice'] ?? 0);
if (!$idm) continue;
if (!isset($cache[$idm])) {
$lq->execute([$idm]);
$ml = $lq->fetchAll(PDO::FETCH_ASSOC);
$mx = [];
try {
$info = MagoProduction::matriceInfo($pdo, $idm);
foreach ($info['mescole'] as $idmx) {
$mq->execute([$idmx]);
if (($n = $mq->fetchColumn()) !== false) $mx[] = $n;
}
} catch (Throwable $e) {
}
$cache[$idm] = [$ml, array_values(array_unique($mx))];
}
[$l['matrice_lines'], $l['matrice_mescole']] = $cache[$idm];
}
unset($l);
}
}
MagoSalesStats::jsonOut([
'success' => true,
'lines' => $lines,
'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;
}
/* date di consegna: ZIBO (scadenza) rossa se superata, cliente (desiderata) arancione */
.dt-zibo {
font-weight: 700;
color: var(--c-ink);
white-space: nowrap;
}
.dt-zibo.late {
color: #dc3545;
}
.dt-client {
font-size: .82rem;
color: #64748b;
white-space: nowrap;
}
.dt-client.late {
color: #ea580c;
font-weight: 700;
}
.dt-tag {
display: inline-block;
font-size: .68rem;
font-weight: 800;
letter-spacing: .3px;
text-transform: uppercase;
margin-right: 4px;
opacity: .75;
}
.dt-fallback {
font-size: .72rem;
color: #94a3b8;
font-style: italic;
}
.kpi-unit {
font-size: .9rem;
font-weight: 700;
color: var(--c-muted);
}
.kpi-week {
margin: 10px -18px -16px;
padding: 7px 18px;
background: #fef3c7;
border-top: 1px solid #fde68a;
color: #92400e;
font-size: .8rem;
transition: background .15s ease;
}
.kpi-week:hover {
background: #fde68a;
}
.kpi-week.selected {
background: #fcd34d;
box-shadow: inset 0 0 0 2px #b45309;
}
/* Da evadere per anno della data ZIBO */
.kpi-years {
margin: 10px -18px -16px;
padding: 8px 18px 10px;
background: #eff6ff;
border-top: 1px solid #bfdbfe;
font-size: .8rem;
color: #1e3a8a;
}
.kpi-years .ky-bar {
display: flex;
height: 6px;
border-radius: 99px;
overflow: hidden;
background: #dbeafe;
margin-bottom: 6px;
}
.kpi-years .ky-bar i {
display: block;
height: 100%;
}
.kpi-years .ky {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 3px 6px;
margin: 0 -6px;
border-radius: 8px;
cursor: pointer;
}
.kpi-years .ky:hover {
background: #dbeafe;
}
.kpi-years .ky.selected {
background: #bfdbfe;
box-shadow: inset 0 0 0 2px #1d4ed8;
}
.kpi-years .ky-dot {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 50%;
margin-right: 5px;
}
.kpi-years .ky-other {
color: #64748b;
padding-top: 2px;
}
.week-badge {
display: inline-block;
background: #fef3c7;
color: #b45309;
font-size: .66rem;
font-weight: 800;
border-radius: 999px;
padding: 1px 7px;
margin-left: 5px;
vertical-align: middle;
}
.more-dates {
display: inline-block;
background: #eef2ff;
color: #4338ca;
font-size: .7rem;
font-weight: 800;
border-radius: 999px;
padding: 1px 7px;
margin-left: 4px;
}
.partial-badge {
background: #fff1e6;
color: #c2410c;
font-weight: 800;
font-size: .75rem;
padding: 3px 8px;
border-radius: 999px;
margin-left: 6px;
}
.late-badge,
.partial-badge,
.late-client-badge {
white-space: nowrap;
display: inline-block;
margin-top: 2px;
}
.late-client-badge {
background: #fff7ed;
color: #ea580c;
font-weight: 800;
font-size: .72rem;
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;
}
}
/* modale larga: quasi tutto lo schermo su PC, a tutto schermo su tablet in verticale e telefono */
.od-dialog.modal-xl {
--bs-modal-width: min(1480px, 96vw);
}
@media (max-width: 991.98px) {
.od-dialog .modal-content {
border-radius: 0 !important;
}
}
@media (min-width: 768px) {
.od-dialog .ro-grid {
grid-template-columns: 150px 1fr 150px 1fr;
}
}
@media (min-width: 1200px) {
.od-dialog .ro-grid {
grid-template-columns: 170px 1fr 170px 1fr;
}
}
/* righe ordine */
.ol-head,
.ol-row {
display: grid;
grid-template-columns: minmax(190px, 1.6fr) minmax(200px, 1.5fr) minmax(150px, 1fr) minmax(150px, 1fr) minmax(120px, .8fr) minmax(200px, 1.4fr);
gap: 10px 16px;
align-items: start;
}
.ol-head {
background: #f1f5f9;
border-radius: 10px;
padding: 8px 14px;
font-size: .78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .03em;
color: #475569;
}
.ol-row {
border: 1px solid #e2e8f0;
border-left: 5px solid #3b82f6;
border-radius: 12px;
padding: 12px 14px;
margin-top: 8px;
background: #fff;
font-size: .9rem;
}
.ol-row.done {
border-left-color: #22c55e;
}
.ol-row.cancel {
border-left-color: #94a3b8;
opacity: .7;
}
.ol-row.late {
border-left-color: #dc3545;
background: #fff7f7;
}
.ol-k {
display: none;
font-size: .7rem;
font-weight: 700;
text-transform: uppercase;
color: #64748b;
letter-spacing: .03em;
margin-bottom: 2px;
}
.ol-n {
display: inline-block;
min-width: 26px;
padding: 1px 6px;
margin-right: 6px;
border-radius: 6px;
background: #e2e8f0;
color: #334155;
font-size: .75rem;
font-weight: 700;
text-align: center;
}
.ol-item {
font-weight: 800;
color: #0f172a;
font-size: .98rem;
}
.ol-sub {
font-size: .8rem;
color: #64748b;
white-space: normal;
}
.ol-mat {
display: flex;
gap: 10px;
align-items: flex-start;
}
.ol-mat img {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 10px;
border: 1px solid #e2e8f0;
background: #f8fafc;
cursor: zoom-in;
flex: 0 0 auto;
}
.ol-mname {
font-weight: 800;
color: #0f172a;
}
.ol-raw {
display: inline-block;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: .78rem;
background: #f1f5f9;
border-radius: 6px;
padding: 1px 6px;
color: #334155;
}
.ol-chip {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 999px;
padding: 2px 9px;
font-size: .74rem;
font-weight: 700;
line-height: 1.5;
margin-top: 3px;
background: color-mix(in srgb, var(--c, #64748b) 14%, #fff);
color: var(--c, #64748b);
border: 1px solid color-mix(in srgb, var(--c, #64748b) 35%, #fff);
}
.ol-qty b {
font-size: 1rem;
}
.ol-bar {
height: 6px;
border-radius: 99px;
background: #e2e8f0;
overflow: hidden;
margin: 5px 0 3px;
}
.ol-bar>span {
display: block;
height: 100%;
background: #22c55e;
border-radius: 99px;
}
.ol-amt {
text-align: right;
}
.ol-amt .amount {
font-weight: 800;
font-size: 1rem;
}
.ol-lpill {
display: inline-block;
border-radius: 8px;
padding: 2px 8px;
margin: 2px 4px 0 0;
font-size: .76rem;
font-weight: 700;
background: color-mix(in srgb, var(--c, #64748b) 18%, #fff);
color: #1e293b;
border: 1px solid color-mix(in srgb, var(--c, #64748b) 45%, #fff);
}
.ol-mrow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px 0;
margin-top: 5px;
}
.ol-mrow>span:first-child {
font-size: .68rem;
font-weight: 700;
text-transform: uppercase;
color: #64748b;
letter-spacing: .03em;
margin-right: 6px;
}
.ol-mx {
display: inline-block;
border-radius: 8px;
padding: 2px 8px;
margin: 2px 4px 0 0;
font-size: .76rem;
font-weight: 600;
background: #f5f3ff;
color: #5b21b6;
border: 1px solid #ddd6fe;
}
.ol-note {
grid-column: 1 / -1;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 8px;
padding: 6px 10px;
font-size: .84rem;
white-space: pre-wrap;
color: #713f12;
}
.ol-warn {
grid-column: 1 / -1;
font-size: .82rem;
color: #b45309;
font-weight: 600;
}
@media (max-width: 1199.98px) {
.ol-head {
display: none;
}
.ol-row {
grid-template-columns: 1fr 1fr;
}
.ol-k {
display: block;
}
.ol-art,
.ol-mat,
.ol-prod {
grid-column: 1 / -1;
}
.ol-amt {
text-align: left;
}
}
@media (max-width: 575.98px) {
.ol-row {
grid-template-columns: 1fr;
}
}
.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-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_summary.php"><i class="fa-solid fa-gauge-high me-1"></i> Sommario</a>
<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>
<a href="sales_customer.php"><i class="fa-solid fa-address-card me-1"></i> Scheda cliente</a>
<?php if (userCan('sales.matrici.view')) : ?>
<a href="sales_matrici.php"><i class="fa-solid fa-shapes me-1"></i> Matrici</a>
<?php endif; ?>
<?php if (userCan('sales.deliveries.view')) : ?>
<a href="sales_orders_deliveries.php"><i class="fa-solid fa-truck-fast me-1"></i> Consegne</a>
<?php endif; ?>
<button type="button" class="sh-page ms-auto align-self-center mb-1" data-help-open="page.stats"><i class="fa-regular fa-circle-question me-1"></i> Come si legge</button>
</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 (ZIBO)</button>
<button type="button" class="chip" data-status="settimana" style="--chip-c:#b45309;">In consegna questa sett.</button>
<button type="button" class="chip" data-status="zibo_anno" style="--chip-c:#1d4ed8;">ZIBO anno in corso</button>
<button type="button" class="chip" data-status="zibo_prossimo" style="--chip-c:#60a5fa;">ZIBO anno prossimo</button>
<button type="button" class="chip" data-status="ritardo_cliente" style="--chip-c:#ea580c;">Oltre data cliente</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" data-help="st.orders">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" data-help="st.value">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" data-help="st.toship">Da evadere</div>
<div class="kpi-value" id="kOpenValue">—</div>
<div class="kpi-sub" id="kOpenValueSub">&nbsp;</div>
<div class="kpi-years" id="kOpenYears"></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" data-help="st.open">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" data-help="st.late">In ritardo</div>
<div class="kpi-value" id="kLate">—</div>
<div class="kpi-sub" id="kLateSub">data ZIBO superata</div>
<div class="kpi-week" id="kWeek" data-status="settimana" title="Ordini con righe da consegnare entro questa settimana (non ancora in ritardo)">&nbsp;</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" data-help="st.done">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" data-help="st.toinvoice">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" data-help="st.customers">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>
<!-- BARRA: metrica dei grafici + personalizza -->
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="chart-title mb-0" style="font-size:1rem;">Grafici in</span>
<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>
<button type="button" data-layout-btn="sales_orders_stats"></button>
</div>
<!-- RIQUADRI (ordine personalizzabile per utente: ui_layout.js) -->
<div class="row g-3 mb-4" <?= UiPrefs::attr($pdo, $iduserlogin ?? 0, 'sales_orders_stats') ?>>
<!-- ELENCO ORDINI -->
<div class="col-12" data-widget="orders" data-title="Elenco ordini">
<div class="chart-card">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="chart-title" data-help="st.list">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 <span class="small fw-normal">(ZIBO / cliente)</span></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 class="col-xl-8" data-widget="monthly" data-title="Andamento mensile">
<div class="chart-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="chart-title" id="ttlMonthly" data-help="st.monthly">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" data-widget="status" data-title="Ripartizione per stato">
<div class="chart-card">
<div class="chart-title" data-help="st.status">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 class="col-xl-7" data-widget="customers" data-title="Top clienti">
<div class="chart-card">
<div class="chart-title" id="ttlCustomers" data-help="st.topcust">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" data-widget="due" data-title="Scadenze ordini aperti">
<div class="chart-card">
<div class="chart-title" id="ttlDue" data-help="st.due">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 class="col-12" data-widget="items" data-title="Top articoli">
<div class="chart-card">
<div class="chart-title" id="ttlItems" data-help="st.items">Top 10 articoli per numero ordini</div>
<div class="chart-hint">Dalle righe ordine · passa sopra una barra per quantità e valore</div>
<div id="chItems" style="min-height:380px;"></div>
</div>
</div>
<div class="col-lg-6" data-widget="area" data-title="Ordini per area">
<div class="chart-card">
<div class="chart-title" id="ttlArea" data-help="st.area">Ordini per area</div>
<div id="chArea" style="min-height:260px;"></div>
</div>
</div>
<div class="col-lg-6" data-widget="agent" data-title="Ordini per agente">
<div class="chart-card">
<div class="chart-title" id="ttlSalesperson" data-help="st.agent">Ordini per agente</div>
<div id="chSalesperson" style="min-height:260px;"></div>
</div>
</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-xl modal-fullscreen-lg-down modal-dialog-scrollable od-dialog">
<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');
$('.kpi-week, .kpi-years .ky').removeClass('selected');
if (f.statuses.length === 1) $(`.kpi[data-status="${f.statuses[0]}"], .kpi-week[data-status="${f.statuses[0]}"], .kpi-years .ky[data-status="${f.statuses[0]}"]`).addClass('selected');
}
// aperta da link (?q=numero ordine): non sovrascrivo i filtri salvati dell'utente
const URL_P = new URLSearchParams(location.search);
const URL_Q = URL_P.get('q');
// aperta dal Sommario (?from=&to=&st=): periodo e stati del box cliccato, senza toccare i filtri salvati
const URL_RANGE = /^\d{4}-\d{2}-\d{2}$/.test(URL_P.get('from') || '') ? {
from: URL_P.get('from'),
to: /^\d{4}-\d{2}-\d{2}$/.test(URL_P.get('to') || '') ? URL_P.get('to') : URL_P.get('from'),
st: (URL_P.get('st') || '').split(',').filter(Boolean)
} : null;
const URL_LINK = !!(URL_Q || URL_RANGE);
function saveFilters(f) {
if (URL_LINK) return;
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');
renderItems(d.top_items || []);
renderTable(d.rows);
if (window.__autoOpen) {
const hit = d.rows.find(r => r.internal_ord_no === window.__autoOpen) || (d.rows.length === 1 ? d.rows[0] : null);
window.__autoOpen = null;
if (hit) openOrder(hit.sale_ord_id);
}
})
.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');
$('#ttlItems').text(v ? 'Top 10 articoli per valore ordinato' : 'Top 10 articoli per numero ordini');
}
// box "Da evadere": residuo delle righe aperte diviso per anno della data ZIBO
function renderToShipYears(t) {
const y = +t.year || new Date().getFullYear();
const cur = +t.y_cur || 0,
next = +t.y_next || 0,
later = +t.y_later || 0,
nodate = +t.y_nodate || 0;
const tot = cur + next + later + nodate;
if (tot <= 0) {
$('#kOpenYears').html('<span class="text-muted">nessuna riga da consegnare</span>');
return;
}
const w = v => (v / tot * 100).toFixed(1) + '%';
let html = '<div class="ky-bar">' +
'<i style="width:' + w(cur) + ';background:#1d4ed8"></i>' +
'<i style="width:' + w(next) + ';background:#60a5fa"></i>' +
'<i style="width:' + w(later + nodate) + ';background:#cbd5e1"></i></div>';
html += '<div class="ky" data-status="zibo_anno" title="Righe aperte con data ZIBO nel ' + y + ', ritardi compresi: clic per vedere gli ordini">' +
'<span><span class="ky-dot" style="background:#1d4ed8"></span>' + y + ' <span class="text-muted">(ritardi compresi)</span></span><b>' + fmtEurShort(cur) + '</b></div>';
html += '<div class="ky" data-status="zibo_prossimo" title="Righe aperte con data ZIBO nel ' + (y + 1) + ': clic per vedere gli ordini">' +
'<span><span class="ky-dot" style="background:#60a5fa"></span>' + (y + 1) + '</span><b>' + fmtEurShort(next) + '</b></div>';
const other = [];
if (later > 0) other.push('dal ' + (y + 2) + ' ' + fmtEurShort(later));
if (nodate > 0) other.push('senza data ZIBO ' + fmtEurShort(nodate));
if (other.length) html += '<div class="ky-other">' + other.join(' · ') + '</div>';
$('#kOpenYears').html(html).attr('title', 'Residuo da consegnare: ' + fmtEur(tot, 2));
}
function renderKpi(k) {
const orders = +k.orders || 0;
$('#kOrders').text(fmtNum(orders));
$('#kOpen').text(fmtNum(k.open_orders));
$('#kLate').html(`${fmtNum(k.late)} <span class="kpi-unit">ordini</span>`);
$('#kLateSub').html(`<b style="color:var(--c-late);">${fmtNum(k.late_lines)}</b> righe in ritardo` +
(+k.late_partial ? ` · ${fmtNum(k.late_partial)} parziali` : '') +
`<br><span style="color:#ea580c;font-weight:700;">${fmtNum(k.late_client)}</span> oltre data cliente`);
$('#kWeek').html(+k.week_orders ?
`<i class="fa-solid fa-calendar-week"></i> <b>${fmtNum(k.week_orders)}</b> in consegna questa sett. · ${fmtNum(k.week_lines)} righe` :
'<i class="fa-solid fa-calendar-check"></i> nessuna consegna in scadenza questa settimana');
$('#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');
renderToShipYears(k.toship || {});
$('#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 renderItems(rows) {
const v = metric === 'value';
const label = r => {
const n = (r.name || '').trim();
const t = n ? `${r.code} · ${n}` : r.code;
return t.length > 60 ? t.substring(0, 58) + '…' : t;
};
upsertChart('items', 'chItems', {
chart: {
...baseChart,
type: 'bar',
height: 380
},
series: [{
name: v ? 'Valore' : 'Ordini',
data: rows.map(r => v ? +r.value : +r.orders)
}],
colors: ['#0f766e'],
xaxis: {
categories: rows.map(label),
labels: {
formatter: fmtMetricAxis
}
},
yaxis: {
labels: {
maxWidth: 380
}
},
plotOptions: {
bar: {
horizontal: true,
borderRadius: 4,
barHeight: '65%'
}
},
dataLabels: {
enabled: false
},
grid: {
borderColor: '#eef2f7'
},
tooltip: {
custom: ({
dataPointIndex
}) => {
const r = rows[dataPointIndex];
if (!r) return '';
return `<div style="padding:8px 12px;font-size:.85rem;">
<div style="font-weight:800;">${esc(r.code)}</div>
<div style="color:#64748b;max-width:320px;white-space:normal;">${esc(r.name)}</div>
<div style="margin-top:6px;">Ordini: <b>${fmtNum(r.orders)}</b></div>
<div>Quantità: <b>${fmtNum(Math.round(r.qty))} ${esc(r.uom)}</b></div>
<div>Valore: <b>${fmtEur(r.value)}</b></div>
</div>`;
}
},
noData: {
text: 'Nessuna riga articolo nel periodo'
}
});
}
// Data ZIBO = settimana di consegna (impostazioni in MagoSalesStats)
const ZIBO_WEEK = <?= MagoSalesStats::ZIBO_IS_WEEK ? 'true' : 'false' ?>;
const ZIBO_END_DAYS = <?= (int)MagoSalesStats::ZIBO_WEEK_END_DAYS ?>;
// scadenza della data ZIBO: fine della settimana indicata
function ziboEnd(z) {
if (!z || !ZIBO_WEEK) return z;
const d = new Date(String(z).substring(0, 10) + 'T00:00:00');
d.setDate(d.getDate() - ((d.getDay() + 6) % 7) + ZIBO_END_DAYS);
return iso(d);
}
// stato della data ZIBO: 'late' (settimana finita), 'week' (settimana in corso), '' (futura)
function ziboState(z, open) {
if (!z || !open) return '';
const today = iso(new Date());
if (ziboEnd(z) < today) return 'late';
if (ZIBO_WEEK && String(z).substring(0, 10) <= today) return 'week';
return '';
}
function ziboHtml(z, open, extra) {
const st = ziboState(z, open);
const title = ZIBO_WEEK ? ` title="Settimana del ${fmtDate(z)}: consegna entro ${fmtDate(ziboEnd(z))}"` : '';
return `<div class="dt-zibo${st === 'late' ? ' late' : ''}"${title}><span class="dt-tag">ZIBO${ZIBO_WEEK ? ' sett.' : ''}</span>${fmtDate(z)}` +
(st === 'week' ? '<span class="week-badge">IN SETTIMANA</span>' : '') + (extra || '') + '</div>';
}
// cella consegna: data ZIBO (rossa a settimana finita, avviso in settimana) + data cliente (arancione se superata)
function dateCell(zibo, client, opts) {
opts = opts || {};
const today = iso(new Date());
const open = opts.open !== false;
const cLate = open && client && client < today;
let html = '';
if (zibo) {
html += ziboHtml(zibo, open, opts.more ? `<span class="more-dates" title="Le righe aperte hanno date di consegna diverse">+${opts.more} date</span>` : '');
} else {
html += `<div class="dt-fallback">data ZIBO non indicata</div>`;
}
if (client) {
html += `<div class="dt-client${cLate ? ' late' : ''}"><span class="dt-tag">Cliente</span>${fmtDate(client)}</div>`;
}
return html || '—';
}
function lateBadges(r) {
if (r.status !== 'aperto' && r.status !== 'bloccato') return '';
const lateL = +r.late_lines,
openL = +r.open_lines;
if (+r.is_late) {
return lateL > 0 && openL > lateL ?
`<span class="partial-badge" title="Righe aperte in ritardo sulla data ZIBO">PARZIALE ${lateL}/${openL} righe</span>` :
'<span class="late-badge">IN RITARDO</span>';
}
return +r.is_late_client ? '<span class="late-client-badge" title="Data ZIBO non ancora superata">OLTRE DATA CLIENTE</span>' : '';
}
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)}<a class="sc-link" href="sales_customer.php?c=${encodeURIComponent(r.customer)}" title="Apri la scheda cliente" onclick="event.stopPropagation();"><i class="fa-solid fa-address-card"></i></a></div>`,
esc(r.your_reference),
`<span data-order="${esc(r.due_date || '')}">${dateCell(r.zibo_date, r.client_date, {
open: r.status === 'aperto' || r.status === 'bloccato',
more: +r.open_dates > 1 ? (+r.open_dates - 1) : 0
})}</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>` + lateBadges(r),
+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').replace(/<\/span>/g, '</span> ')).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').replace(/<\/span>/g, '</span> ')).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><a href="sales_customer.php?c=${encodeURIComponent(o.customer)}" style="color:inherit;" title="Apri la scheda cliente">${esc(o.company_name)} <span style="opacity:.8;">(${esc(o.customer)})</span> <i class="fa-solid fa-address-card"></i></a></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</div><div>${dateCell(o.zibo_date, o.client_date, {open: o.status === 'aperto' || o.status === 'bloccato'})}${+o.open_lines ? ` <span class="small text-muted">prima riga da consegnare · ${fmtNum(o.open_lines)} righe aperte${+o.late_lines ? `, <b style="color:#dc3545;">${fmtNum(o.late_lines)} in ritardo</b>` : ''}</span>` : ''}</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>
${renderLines(d.lines || [])}
${o.notes ? `<div class="p-3 rounded mt-3" style="background:#f8fafc;border:1px solid #e2e8f0;white-space:pre-wrap;"><b>Note:</b><br>${esc(o.notes)}</div>` : ''}
`);
});
}
const MATCH = {
esatta: ['Matrice riconosciuta', '#15803d'],
revisione: ['Altra revisione in anagrafica', '#b45309'],
mappa: ['Abbinata a mano', '#0f766e'],
manuale: ['Scelta a mano', '#0f766e'],
da_creare: ['Da creare in anagrafica', '#dc2626'],
profilo: ['Profilo: matrice da scegliere', '#7c3aed'],
anomalia: ['Da verificare', '#dc2626']
};
function lineMatrix(l) {
const raw = (l.matrice_misura || '').trim();
const mm = l.mm || {};
if (!raw && !l.matrice) return '<span class="text-muted">—</span>';
const misura = mm.misura ? `${esc(mm.tipo_label || '')} ${esc(mm.misura)}`.trim() : '';
const m = MATCH[l.match_status];
const img = l.matrice_photo ? `<img src="photos/matrici/${encodeURIComponent(l.matrice_photo)}" alt="" loading="lazy" class="ol-photo" title="Ingrandisci">` : '';
return `${img}<div>
${l.matrice ? `<div class="ol-mname">${esc(l.matrice)}</div>` : ''}
${raw ? `<div class="ol-sub">Mago <span class="ol-raw">${esc(raw)}</span>${misura ? ' · ' + misura : ''}</div>` : ''}
${m ? `<span class="ol-chip" style="--c:${m[1]};" title="${esc(l.match_info || '')}">${m[0]}</span>` : ''}
${(l.matrice_lines || []).length ? `<div class="ol-mrow"><span>Linee</span>${l.matrice_lines.map(x => `<span class="ol-lpill" style="--c:${esc(x.color || '#64748b')};">${esc(x.name)}</span>`).join('')}</div>` : ''}
${(l.matrice_mescole || []).length ? `<div class="ol-mrow"><span>Mescole</span>${l.matrice_mescole.map(x => `<span class="ol-mx">${esc(x)}</span>`).join('')}</div>` : ''}
</div>`;
}
function lineProduction(l, open) {
const pills = arr => arr.map(x => `<span class="ol-lpill" style="--c:${esc(x.color || '#64748b')};">${esc(x.name)}</span>`).join('');
let h = '';
if (l.prod_status) {
h += `<span class="ol-chip" style="--c:${esc(l.prod_status_color)};"><i class="fa-solid fa-industry"></i> ${esc(l.prod_status_name)}</span>`;
if (l.linea) h += `<div>${pills([{ name: l.linea, color: l.linea_color }])}</div>`;
const bits = [];
if (+l.prod_metri) bits.push(fmtNum(Math.round(+l.prod_metri)) + ' m');
if (l.data_produzione) bits.push('prod. ' + fmtDate(l.data_produzione));
if (l.mescole) bits.push(esc(l.mescole));
if (bits.length) h += `<div class="ol-sub">${bits.join(' · ')}</div>`;
} else if (l.queue_status === 'scartato') {
h += `<span class="ol-chip" style="--c:#64748b;">Non da produrre</span>${l.discard_reason ? `<div class="ol-sub">${esc(l.discard_reason)}</div>` : ''}`;
} else if (l.queue_status === 'chiuso_mago') {
h += `<span class="ol-chip" style="--c:#64748b;">Chiusa in Mago</span>`;
} else if (l.queue_status === 'importato') {
h += `<span class="ol-chip" style="--c:#64748b;">Importata, lavoro non più presente</span>`;
} else {
h += `<span class="ol-sub">${open ? 'Non ancora in produzione' : '—'}</span>`;
}
return h;
}
function renderLines(lines) {
if (!lines.length) {
return '<div class="warn-strip">Righe non ancora sincronizzate: premi <b>Aggiorna da Mago</b>.</div>';
}
const today = iso(new Date());
const qtyTxt = n => fmtNum(Math.round(+n * 100) / 100);
const rows = lines.map(l => {
const open = !+l.delivered && !+l.cancelled;
const due = l.zibo_date ? ziboEnd(l.zibo_date) : l.client_date;
const late = open && due && due < today;
const days = late ? Math.round((new Date(today) - new Date(due)) / 86400000) : 0;
const st = +l.cancelled ? '<span class="st-badge st-annullato">Annullata</span>' :
(+l.delivered ? '<span class="st-badge st-evaso">Consegnata</span>' :
'<span class="st-badge st-aperto">Aperta</span>' + (late ? `<span class="late-badge">+${days} gg</span>` : ''));
const pct = +l.qty > 0 ? Math.min(100, Math.round(+l.delivered_qty / +l.qty * 100)) : 0;
const cls = +l.cancelled ? 'cancel' : (+l.delivered ? 'done' : (late ? 'late' : ''));
return `<div class="ol-row ${cls}">
<div class="ol-art">
<div><span class="ol-n">${esc(l.line)}</span><span class="ol-item">${esc(l.item)}</span></div>
<div class="ol-sub">${esc(l.description)}</div>
${l.cod_art_cli ? `<div class="ol-sub">Cod. cliente <b>${esc(l.cod_art_cli)}</b></div>` : ''}
</div>
<div><div class="ol-k">Matrice</div><div class="ol-mat">${lineMatrix(l)}</div></div>
<div class="ol-qty">
<div class="ol-k">Quantità</div>
<b class="amount">${qtyTxt(l.qty)}</b> ${esc(l.uom)}
<div class="ol-bar"><span style="width:${pct}%;"></span></div>
<div class="ol-sub">consegnati ${qtyTxt(l.delivered_qty)}${open ? ` · residuo <b>${qtyTxt(l.residual_qty)}</b>` : ''}</div>
</div>
<div><div class="ol-k">Consegna</div>${dateCell(l.zibo_date, l.client_date, {open: open})}</div>
<div class="ol-amt"><div class="ol-k">Imponibile</div><div class="amount">${fmtEur(l.taxable_amount, 2)}</div><div class="mt-1">${st}</div></div>
<div class="ol-prod"><div class="ol-k">Produzione</div>${lineProduction(l, open)}</div>
${+l.mago_changed ? `<div class="ol-warn"><i class="fa-solid fa-triangle-exclamation me-1"></i>Cambiata in Mago dopo l'invio in produzione${l.mago_changes ? ': ' + esc(l.mago_changes) : ''}</div>` : ''}
${l.notes ? `<div class="ol-note"><b>Note riga:</b> ${esc(l.notes)}</div>` : ''}
</div>`;
}).join('');
return `
<div class="fw-bold mb-2" style="color:var(--c-ink);">Righe ordine (${lines.length})</div>
<div class="ol-head"><div>Articolo</div><div>Matrice</div><div>Quantità</div><div>Consegna (ZIBO / cliente)</div><div class="text-end">Imponibile · stato</div><div>Produzione</div></div>
${rows}`;
}
// foto matrice a tutto schermo
$(document).on('click', '#orderModalBody .ol-photo', function() {
Swal.fire({
imageUrl: this.src,
imageAlt: 'Matrice',
showConfirmButton: false,
showCloseButton: true,
width: 'auto',
customClass: {
image: 'm-0'
}
});
});
/* ---------------- 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.righe ? `<br>Righe lette: <b>${fmtNum(d.righe.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));
// striscia "in consegna questa settimana" dentro il box rosso: filtra solo quegli ordini
// righe "anno" dentro il box Da evadere: filtrano gli ordini con consegne ZIBO in quell'anno
$('#kpiGrid').on('click', '.kpi-years .ky', function(e) {
e.stopPropagation();
toggleStatus($(this).data('status'), true);
});
$('#kpiGrid').on('click', '.kpi-years', function(e) {
e.stopPropagation();
});
$('#kpiGrid').on('click', '.kpi-week', function(e) {
e.stopPropagation();
toggleStatus('settimana', true);
});
$('#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 = URL_LINK ? null : restoreFilters();
if (saved && saved.metric === 'value') metric = 'value';
if (URL_Q) {
// tutto lo storico, solo l'ordine cercato; se è uno solo apro il dettaglio
$('#fFrom').val((d && d.range && d.range.min_date) || '2020-01-01');
$('#fTo').val(iso(new Date()));
$('#periodChips .chip').removeClass('active');
$('#fSearch').val(URL_Q);
window.__autoOpen = URL_Q;
} else if (URL_RANGE) {
$('#fFrom').val(URL_RANGE.from);
$('#fTo').val(URL_RANGE.to);
$('#periodChips .chip').removeClass('active');
URL_RANGE.st.forEach(st => $(`#statusChips .chip[data-status="${st}"]`).addClass('active'));
}
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 if (!URL_LINK) {
setPeriod('y' + new Date().getFullYear(), true);
}
loadData();
});
});
})();
</script>
<script src="sales_help.js?v=7"></script>
<script src="ui_layout.js?v=1"></script>
</body>
</html>