2357 lines
104 KiB
PHP
2357 lines
104 KiB
PHP
<?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.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];
|
||
|
||
// --- 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']);
|
||
|
||
$stL = $pdo->prepare("
|
||
SELECT 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
|
||
FROM mago_sale_ord_detail d
|
||
WHERE d.sale_ord_id = ?
|
||
ORDER BY d.position, d.line, d.sub_id
|
||
");
|
||
$stL->execute([$id]);
|
||
$lines = $stL->fetchAll(PDO::FETCH_ASSOC);
|
||
|
||
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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
}
|
||
|
||
.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_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.deliveries.view')) : ?>
|
||
<a href="sales_orders_deliveries.php"><i class="fa-solid fa-truck-fast me-1"></i> Consegne</a>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<!-- FILTRI -->
|
||
<div class="filters-box mb-4">
|
||
<div class="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="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">Ordini</div>
|
||
<div class="kpi-value" id="kOrders">—</div>
|
||
<div class="kpi-sub" id="kOrdersSub"> </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"> </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"> </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"> </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" 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)"> </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"> </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"> </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-3">
|
||
<div class="col-12">
|
||
<div class="chart-card">
|
||
<div class="chart-title" id="ttlItems">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>
|
||
|
||
<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 <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>
|
||
</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 => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
} [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').removeClass('selected');
|
||
if (f.statuses.length === 1) $(`.kpi[data-status="${f.statuses[0]}"], .kpi-week[data-status="${f.statuses[0]}"]`).addClass('selected');
|
||
}
|
||
|
||
// aperta da link (?q=numero ordine): non sovrascrivo i filtri salvati dell'utente
|
||
const URL_Q = new URLSearchParams(location.search).get('q');
|
||
|
||
function saveFilters(f) {
|
||
if (URL_Q) 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');
|
||
}
|
||
|
||
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` : ' '));
|
||
$('#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' : ' ';
|
||
$('#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` : ' ');
|
||
|
||
if (k.prev_orders === null || k.prev_orders === undefined) {
|
||
$('#kOrdersSub').html(' ');
|
||
} 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>` : ''}
|
||
|
||
`);
|
||
});
|
||
}
|
||
|
||
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 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 qtyTxt = n => fmtNum(Math.round(+n * 100) / 100);
|
||
return `<tr>
|
||
<td><b>${esc(l.item)}</b><div class="small text-muted" style="white-space:normal;max-width:320px;">${esc(l.description)}</div></td>
|
||
<td class="text-end amount">${qtyTxt(l.qty)} ${esc(l.uom)}</td>
|
||
<td class="text-end amount">${qtyTxt(l.delivered_qty)}</td>
|
||
<td class="text-end amount">${open ? '<b>' + qtyTxt(l.residual_qty) + '</b>' : '<span class="text-muted">—</span>'}</td>
|
||
<td>${dateCell(l.zibo_date, l.client_date, {open: open})}</td>
|
||
<td class="text-end amount">${fmtEur(l.taxable_amount, 2)}</td>
|
||
<td>${st}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
return `
|
||
<div class="fw-bold mb-2" style="color:var(--c-ink);">Righe ordine (${lines.length})</div>
|
||
<div class="table-responsive">
|
||
<table class="table table-sm align-middle" style="font-size:.9rem;">
|
||
<thead style="background:#f1f5f9;">
|
||
<tr>
|
||
<th>Articolo</th><th class="text-end">Qtà</th><th class="text-end">Consegnata</th><th class="text-end">Residuo</th>
|
||
<th>Consegna (ZIBO / cliente)</th><th class="text-end">Imponibile</th><th>Stato</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</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.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
|
||
$('#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_Q ? 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;
|
||
}
|
||
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_Q) {
|
||
setPeriod('y' + new Date().getFullYear(), true);
|
||
}
|
||
loadData();
|
||
});
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
|
||
</html>
|