4413 lines
195 KiB
PHP
4413 lines
195 KiB
PHP
<?php
|
||
ob_start();
|
||
ini_set('display_errors', 1);
|
||
error_reporting(E_ALL);
|
||
|
||
include('include/headscript.php');
|
||
require_once __DIR__ . '/class/MagoSalesStats.php';
|
||
require_once __DIR__ . '/class/MagoMatriceStats.php';
|
||
require_once __DIR__ . '/class/UiPrefs.php';
|
||
|
||
$db = DBHandlerSelect::getInstance();
|
||
$pdo = $db->getConnection();
|
||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||
|
||
// Permesso specifico: prezzi di vendita per matrice e cliente (dati riservati)
|
||
if (!userCan('sales.matrici.view')) {
|
||
if (isset($_GET['action'])) {
|
||
MagoSalesStats::jsonOut(['success' => 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;
|
||
}
|
||
$canDeliveries = userCan('sales.deliveries.view');
|
||
|
||
/* ============================================================
|
||
* AJAX
|
||
* ============================================================ */
|
||
function mx_date($v, $def)
|
||
{
|
||
$v = (string)$v;
|
||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) ? $v : $def;
|
||
}
|
||
function mx_customers()
|
||
{
|
||
$c = trim((string)($_GET['c'] ?? ''));
|
||
return $c === '' ? [] : array_slice(array_values(array_filter(array_map('trim', explode(',', $c)))), 0, 200);
|
||
}
|
||
|
||
$action = $_GET['action'] ?? '';
|
||
|
||
if ($action === 'filters') {
|
||
MagoSalesStats::jsonOut(MagoSalesStats::filterOptions($pdo));
|
||
}
|
||
|
||
if ($action === 'list' || $action === 'detail') {
|
||
try {
|
||
$from = mx_date($_GET['from'] ?? '', date('Y-m-d', strtotime('-365 days')));
|
||
$to = mx_date($_GET['to'] ?? '', date('Y-m-d'));
|
||
if ($from < MagoMatriceStats::SINCE) $from = MagoMatriceStats::SINCE;
|
||
if ($to < $from) $to = $from;
|
||
if ($action === 'list') {
|
||
MagoSalesStats::jsonOut(MagoMatriceStats::buildList($pdo, $from, $to, mx_customers()));
|
||
}
|
||
MagoSalesStats::jsonOut(MagoMatriceStats::buildDetail($pdo, (string)($_GET['k'] ?? ''), $from, $to, mx_customers()));
|
||
} catch (Throwable $e) {
|
||
MagoSalesStats::jsonOut(['success' => false, 'message' => $e->getMessage()]);
|
||
}
|
||
}
|
||
?>
|
||
<!doctype html>
|
||
<html lang="it">
|
||
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
|
||
<?php include('cssinclude.php'); ?>
|
||
<title>Matrici: prezzi e mescole - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
|
||
|
||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||
<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>
|
||
<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>
|
||
<script src="https://cdn.jsdelivr.net/npm/apexcharts@3.49.1"></script>
|
||
|
||
<style>
|
||
:root {
|
||
--c-ink: #1f2d3d;
|
||
--c-muted: #64748b;
|
||
--c-line: #e2e8f0;
|
||
--mx: #7c3aed;
|
||
}
|
||
|
||
body {
|
||
font-size: 1.02rem;
|
||
background: #f8fafc;
|
||
}
|
||
|
||
.card {
|
||
border-radius: 16px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, .08);
|
||
}
|
||
|
||
.back-dashboard {
|
||
background: #b9ebc7 !important;
|
||
color: var(--c-ink) !important;
|
||
border-radius: 10px;
|
||
font-weight: 600;
|
||
padding: 10px 18px;
|
||
}
|
||
|
||
.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, #7c3aed, #c026d3);
|
||
color: #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 1.5rem;
|
||
box-shadow: 0 6px 16px rgba(124, 58, 237, .28);
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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.active {
|
||
background: #fff;
|
||
color: #198754;
|
||
border-color: var(--c-line);
|
||
border-bottom-color: #fff;
|
||
}
|
||
|
||
|
||
.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);
|
||
}
|
||
|
||
.flabel {
|
||
font-weight: 700;
|
||
font-size: .8rem;
|
||
color: var(--c-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: .4px;
|
||
margin-bottom: 4px;
|
||
display: block;
|
||
}
|
||
|
||
.mxrow {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
|
||
.mxchip {
|
||
border: 1.5px solid var(--c-line);
|
||
background: #fff;
|
||
color: var(--c-ink);
|
||
border-radius: 999px;
|
||
padding: 8px 15px;
|
||
font-weight: 700;
|
||
font-size: .92rem;
|
||
cursor: pointer;
|
||
min-height: 42px;
|
||
}
|
||
|
||
.mxchip.active {
|
||
background: var(--mx);
|
||
border-color: var(--mx);
|
||
color: #fff;
|
||
}
|
||
|
||
.mxchip .n {
|
||
font-size: .75rem;
|
||
opacity: .75;
|
||
margin-left: 4px;
|
||
}
|
||
|
||
.mx-date {
|
||
border-radius: 10px;
|
||
min-height: 42px;
|
||
max-width: 165px;
|
||
}
|
||
|
||
.select2-container--bootstrap-5 .select2-selection {
|
||
border-radius: 10px;
|
||
min-height: 42px;
|
||
}
|
||
|
||
.only-banner {
|
||
background: #f5f3ff;
|
||
border: 1px solid #ddd6fe;
|
||
color: #4c1d95;
|
||
border-radius: 12px;
|
||
padding: 10px 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* KPI */
|
||
.mxk-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||
gap: 14px;
|
||
}
|
||
|
||
@media (min-width: 1400px) {
|
||
.mxk-grid {
|
||
grid-template-columns: repeat(7, 1fr);
|
||
}
|
||
}
|
||
|
||
.mg-pill {
|
||
display: inline-block;
|
||
font-weight: 800;
|
||
font-size: .8rem;
|
||
border-radius: 999px;
|
||
padding: 2px 9px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.mg-hi {
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
}
|
||
|
||
.mg-mid {
|
||
background: #fef9c3;
|
||
color: #854d0e;
|
||
}
|
||
|
||
.mg-lo {
|
||
background: #fee2e2;
|
||
color: #991b1b;
|
||
}
|
||
|
||
.tb-check {
|
||
background: #ffedd5;
|
||
color: #9a3412;
|
||
}
|
||
|
||
.mg-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 10px;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
border: 1px solid transparent;
|
||
}
|
||
|
||
.mg-item:hover {
|
||
background: #f8fafc;
|
||
border-color: var(--c-line);
|
||
}
|
||
|
||
.mg-item .nm {
|
||
font-weight: 700;
|
||
color: var(--c-ink);
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.mg-item .pr {
|
||
font-size: .8rem;
|
||
color: var(--c-muted);
|
||
}
|
||
|
||
.mxk {
|
||
background: color-mix(in srgb, var(--k) 11%, #fff);
|
||
border: 1px solid color-mix(in srgb, var(--k) 26%, #fff);
|
||
border-radius: 16px;
|
||
padding: 15px 16px;
|
||
position: relative;
|
||
}
|
||
|
||
.mxk .lbl {
|
||
font-size: .76rem;
|
||
font-weight: 800;
|
||
text-transform: uppercase;
|
||
letter-spacing: .5px;
|
||
color: color-mix(in srgb, var(--k) 70%, #1f2d3d);
|
||
padding-right: 38px;
|
||
}
|
||
|
||
.mxk .ico {
|
||
position: absolute;
|
||
right: 12px;
|
||
top: 12px;
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
color: var(--k);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.mxk .big {
|
||
font-size: 1.6rem;
|
||
font-weight: 800;
|
||
color: var(--c-ink);
|
||
line-height: 1.15;
|
||
margin-top: 4px;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.mxk .sub {
|
||
font-size: .83rem;
|
||
color: var(--c-muted);
|
||
margin-top: 3px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.mxk .sub b {
|
||
color: var(--c-ink);
|
||
}
|
||
|
||
.mxk.click {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.mxbar {
|
||
height: 7px;
|
||
border-radius: 99px;
|
||
background: #eef2f7;
|
||
overflow: hidden;
|
||
display: flex;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.mxbar i {
|
||
display: block;
|
||
height: 100%;
|
||
}
|
||
|
||
/* card */
|
||
.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);
|
||
}
|
||
|
||
|
||
.seg {
|
||
display: inline-flex;
|
||
background: #f1f5f9;
|
||
border-radius: 12px;
|
||
padding: 4px;
|
||
gap: 4px;
|
||
}
|
||
|
||
.seg button {
|
||
border: 0;
|
||
background: transparent;
|
||
padding: 7px 14px;
|
||
border-radius: 9px;
|
||
font-weight: 700;
|
||
color: var(--c-muted);
|
||
}
|
||
|
||
.seg button.active {
|
||
background: #fff;
|
||
color: var(--c-ink);
|
||
box-shadow: 0 2px 6px rgba(15, 23, 42, .1);
|
||
}
|
||
|
||
/* prezzi in movimento */
|
||
.mv-col h6 {
|
||
font-weight: 800;
|
||
font-size: .85rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: .4px;
|
||
}
|
||
|
||
.mv-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 9px 10px;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
border: 1px solid transparent;
|
||
}
|
||
|
||
.mv-item:hover {
|
||
background: #f8fafc;
|
||
border-color: var(--c-line);
|
||
}
|
||
|
||
.mv-item .nm {
|
||
font-weight: 700;
|
||
color: var(--c-ink);
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.mv-item .pr {
|
||
font-size: .8rem;
|
||
color: var(--c-muted);
|
||
}
|
||
|
||
.mv-item .pct {
|
||
margin-left: auto;
|
||
font-weight: 800;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* tabelle */
|
||
.mx-table thead th {
|
||
background: #ede9fe;
|
||
color: var(--c-ink);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.mx-table tbody tr {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.amount {
|
||
font-variant-numeric: tabular-nums;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.mx-thumb {
|
||
width: 42px;
|
||
height: 42px;
|
||
border-radius: 10px;
|
||
object-fit: cover;
|
||
background: #f1f5f9;
|
||
border: 1px solid var(--c-line);
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.mx-thumb.ph {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #cbd5e1;
|
||
}
|
||
|
||
.mx-name {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.mx-name b {
|
||
color: var(--c-ink);
|
||
}
|
||
|
||
.tb {
|
||
display: inline-block;
|
||
font-size: .68rem;
|
||
font-weight: 800;
|
||
border-radius: 999px;
|
||
padding: 2px 8px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.tb-esatta {
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
}
|
||
|
||
.tb-revisione {
|
||
background: #fef9c3;
|
||
color: #854d0e;
|
||
}
|
||
|
||
.tb-mappa {
|
||
background: #e0f2fe;
|
||
color: #075985;
|
||
}
|
||
|
||
.tb-da_creare {
|
||
background: #ffedd5;
|
||
color: #9a3412;
|
||
}
|
||
|
||
.tb-profilo {
|
||
background: #ede9fe;
|
||
color: #5b21b6;
|
||
}
|
||
|
||
.tb-altro {
|
||
background: #e2e8f0;
|
||
color: #334155;
|
||
}
|
||
|
||
.tb-M {
|
||
background: #ede9fe;
|
||
color: #5b21b6;
|
||
}
|
||
|
||
.tb-T {
|
||
background: #e0f2fe;
|
||
color: #075985;
|
||
}
|
||
|
||
.tb-R {
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
}
|
||
|
||
.tb-D {
|
||
background: #fce7f3;
|
||
color: #9d174d;
|
||
}
|
||
|
||
.tb-Q {
|
||
background: #ccfbf1;
|
||
color: #115e59;
|
||
}
|
||
|
||
.tb-S {
|
||
background: #fef3c7;
|
||
color: #92400e;
|
||
}
|
||
|
||
.tb-X {
|
||
background: #e2e8f0;
|
||
color: #334155;
|
||
}
|
||
|
||
.tb-warn {
|
||
background: #fff7ed;
|
||
color: #c2410c;
|
||
border: 1px solid #fed7aa;
|
||
}
|
||
|
||
.tb-pz {
|
||
background: #fef3c7;
|
||
color: #92400e;
|
||
}
|
||
|
||
.tb-gm {
|
||
background: #fee2e2;
|
||
color: #991b1b;
|
||
}
|
||
|
||
.uom {
|
||
display: inline-block;
|
||
font-weight: 800;
|
||
font-size: .75rem;
|
||
border-radius: 6px;
|
||
padding: 2px 7px;
|
||
background: #f1f5f9;
|
||
color: #334155;
|
||
}
|
||
|
||
.spread {
|
||
display: block;
|
||
width: 90px;
|
||
height: 6px;
|
||
border-radius: 99px;
|
||
background: #eef2f7;
|
||
overflow: hidden;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.spread i {
|
||
display: block;
|
||
height: 100%;
|
||
border-radius: 99px;
|
||
}
|
||
|
||
.delta-up {
|
||
color: #198754;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.delta-down {
|
||
color: #dc3545;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.spk {
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.margin-card {
|
||
background: repeating-linear-gradient(135deg, #faf5ff, #faf5ff 12px, #f5f3ff 12px, #f5f3ff 24px);
|
||
border: 1.5px dashed #c4b5fd;
|
||
border-radius: 16px;
|
||
padding: 18px;
|
||
color: #4c1d95;
|
||
}
|
||
|
||
.dataTables_wrapper .dt-buttons .btn {
|
||
border-radius: 10px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 50px 20px;
|
||
color: var(--c-muted);
|
||
}
|
||
|
||
/* dettaglio */
|
||
.dm-head {
|
||
display: flex;
|
||
gap: 16px;
|
||
align-items: flex-start;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.dm-photo {
|
||
width: 110px;
|
||
height: 110px;
|
||
border-radius: 16px;
|
||
object-fit: cover;
|
||
border: 1px solid var(--c-line);
|
||
background: #f8fafc;
|
||
cursor: zoom-in;
|
||
}
|
||
|
||
.dm-title {
|
||
font-size: 1.35rem;
|
||
font-weight: 800;
|
||
color: var(--c-ink);
|
||
}
|
||
|
||
.dm-facts {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.fact {
|
||
background: #fff;
|
||
border: 1px solid var(--c-line);
|
||
border-radius: 10px;
|
||
padding: 5px 10px;
|
||
font-size: .85rem;
|
||
color: var(--c-muted);
|
||
}
|
||
|
||
.fact b {
|
||
color: var(--c-ink);
|
||
}
|
||
|
||
.lchip {
|
||
display: inline-block;
|
||
border-radius: 999px;
|
||
padding: 3px 10px;
|
||
font-size: .78rem;
|
||
font-weight: 800;
|
||
color: #1f2d3d;
|
||
}
|
||
|
||
.dev-pill {
|
||
display: inline-block;
|
||
font-weight: 800;
|
||
font-size: .78rem;
|
||
border-radius: 999px;
|
||
padding: 2px 9px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.dev-pill.hi {
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
}
|
||
|
||
.dev-pill.lo {
|
||
background: #fee2e2;
|
||
color: #991b1b;
|
||
}
|
||
|
||
.dev-pill.eq {
|
||
background: #f1f5f9;
|
||
color: #475569;
|
||
}
|
||
|
||
.dm-tabs {
|
||
display: flex;
|
||
gap: 4px;
|
||
flex-wrap: wrap;
|
||
background: #f1f5f9;
|
||
border-radius: 12px;
|
||
padding: 4px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.dm-tabs button {
|
||
border: 0;
|
||
background: transparent;
|
||
padding: 9px 16px;
|
||
border-radius: 9px;
|
||
font-weight: 700;
|
||
color: var(--c-muted);
|
||
}
|
||
|
||
.dm-tabs button.active {
|
||
background: #fff;
|
||
color: var(--c-ink);
|
||
box-shadow: 0 2px 6px rgba(15, 23, 42, .1);
|
||
}
|
||
|
||
.ord-link {
|
||
font-weight: 700;
|
||
text-decoration: none;
|
||
}
|
||
</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">
|
||
<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-shapes"></i></div>
|
||
<div>
|
||
<h5 class="mb-1" style="font-weight:800;">Matrici: prezzi e mescole</h5>
|
||
<span class="sync-pill" id="syncPill"><span class="dot"></span><span id="syncText">Dati Mago4</span></span>
|
||
</div>
|
||
</div>
|
||
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">↩️ Torna alla Dashboard</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card-body pt-0">
|
||
<div class="stats-tabs">
|
||
<a href="sales_summary.php"><i class="fa-solid fa-gauge-high me-1"></i> Sommario</a>
|
||
<a href="sales_orders_stats.php"><i class="fa-solid fa-list-check me-1"></i> Andamento ordini</a>
|
||
<a href="sales_orders_years.php"><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>
|
||
<a href="sales_matrici.php" class="active"><i class="fa-solid fa-shapes me-1"></i> Matrici</a>
|
||
<?php if ($canDeliveries) : ?>
|
||
<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.matrici"><i class="fa-regular fa-circle-question me-1"></i> Come si legge</button>
|
||
</div>
|
||
|
||
<!-- FILTRI -->
|
||
<div class="filters-box mb-3">
|
||
<div class="row g-3">
|
||
<div class="col-xl-7">
|
||
<label class="flabel">Periodo</label>
|
||
<div class="mxrow" id="fPeriod">
|
||
<button type="button" class="mxchip" data-p="12m">Ultimi 12 mesi</button>
|
||
<button type="button" class="mxchip" data-p="ytd">Anno in corso</button>
|
||
<button type="button" class="mxchip" data-p="ly">Anno scorso</button>
|
||
<button type="button" class="mxchip" data-p="24m">24 mesi</button>
|
||
<button type="button" class="mxchip" data-p="36m">36 mesi</button>
|
||
<button type="button" class="mxchip" data-p="all">Tutto</button>
|
||
<input type="date" class="form-control mx-date" id="fFrom" title="Dal">
|
||
<input type="date" class="form-control mx-date" id="fTo" title="Al">
|
||
</div>
|
||
</div>
|
||
<div class="col-xl-5">
|
||
<label class="flabel" for="fCustomers">Clienti</label>
|
||
<div class="d-flex gap-2">
|
||
<div class="flex-grow-1"><select id="fCustomers" multiple style="width:100%;"></select></div>
|
||
<button type="button" class="btn btn-light" id="btnReset" title="Azzera i filtri" style="border-radius:10px;min-width:46px;">🧽</button>
|
||
</div>
|
||
</div>
|
||
<div class="col-xl-8">
|
||
<label class="flabel" data-help="mx.types">Tipo</label>
|
||
<div class="mxrow" id="fType">
|
||
<button type="button" class="mxchip" data-t="all">Tutto</button>
|
||
<button type="button" class="mxchip" data-t="M">Matrici</button>
|
||
<button type="button" class="mxchip" data-t="T">Tubi</button>
|
||
<button type="button" class="mxchip" data-t="R">Rettangoli</button>
|
||
<button type="button" class="mxchip" data-t="D">Diametri</button>
|
||
<button type="button" class="mxchip" data-t="other">Altri</button>
|
||
<button type="button" class="mxchip" data-t="todo" title="Matrici non in anagrafica, altra revisione e valori non riconosciuti">Da sistemare</button>
|
||
</div>
|
||
</div>
|
||
<div class="col-xl-4">
|
||
<label class="flabel">Unità di vendita</label>
|
||
<div class="mxrow" id="fUom">
|
||
<button type="button" class="mxchip" data-u="all">Tutte</button>
|
||
<button type="button" class="mxchip" data-u="m">Metri</button>
|
||
<button type="button" class="mxchip" data-u="kg">Chili</button>
|
||
<button type="button" class="mxchip" data-u="pz">Pezzi</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="onlyBanner" class="only-banner mb-3" style="display:none;"></div>
|
||
|
||
<div id="loading" class="empty-state">
|
||
<div class="spinner-border text-secondary mb-2"></div>
|
||
<div>Carico le righe d'ordine…</div>
|
||
</div>
|
||
|
||
<div id="content" style="display:none;">
|
||
<div class="mxk-grid mb-3" id="kpis"></div>
|
||
|
||
<div class="d-flex justify-content-end mb-2"><button type="button" data-layout-btn="sales_matrici"></button></div>
|
||
<div class="row g-3 mb-3" <?= UiPrefs::attr($pdo, $iduserlogin ?? 0, 'sales_matrici') ?>>
|
||
|
||
<div class="col-xl-7" data-widget="map" data-title="Mappa dei prezzi">
|
||
<div class="chart-card">
|
||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||
<div>
|
||
<div class="chart-title" data-help="mx.map">Mappa dei prezzi</div>
|
||
<div class="chart-hint">Ogni bolla è una matrice: più a destra = più venduta, più in alto = prezzo più alto, più grande = più fatturato. Clic per il dettaglio.</div>
|
||
</div>
|
||
<div class="seg" id="mapUom">
|
||
<button type="button" data-u="m" class="active">€/metro</button>
|
||
<button type="button" data-u="kg">€/kg</button>
|
||
</div>
|
||
</div>
|
||
<div id="chMap" style="min-height:380px;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-xl-5" data-widget="moves" data-title="Prezzi in movimento">
|
||
<div class="chart-card">
|
||
<div class="chart-title" data-help="mx.moves">Prezzi in movimento</div>
|
||
<div class="chart-hint mb-2">Prezzo medio degli ultimi 12 mesi rispetto ai 12 precedenti</div>
|
||
<div class="row g-2">
|
||
<div class="col-md-6 col-xl-12 col-xxl-6 mv-col">
|
||
<h6 class="text-success"><i class="fa-solid fa-arrow-trend-up me-1"></i> In aumento</h6>
|
||
<div id="mvUp"></div>
|
||
</div>
|
||
<div class="col-md-6 col-xl-12 col-xxl-6 mv-col">
|
||
<h6 class="text-danger"><i class="fa-solid fa-arrow-trend-down me-1"></i> In calo</h6>
|
||
<div id="mvDown"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-12" data-widget="table" data-title="Classifica matrici">
|
||
<div class="chart-card">
|
||
<div class="chart-title" data-help="mx.table">Classifica matrici</div>
|
||
<div class="chart-hint mb-2">Prezzo medio = imponibile delle righe ÷ quantità, nell'unità in cui è venduta. Clic su una riga per clienti, prezzi nel tempo e mescole.</div>
|
||
<div class="table-responsive">
|
||
<table id="tMat" class="table table-striped align-middle w-100 mx-table"></table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-xl-5" data-widget="mixtable" data-title="Fabbisogno mescole">
|
||
<div class="chart-card">
|
||
<div class="chart-title" data-help="mx.mixneed">Fabbisogno mescole</div>
|
||
<div class="chart-hint mb-2">Venduto nel periodo e <b>ancora da produrre</b> per gli ordini aperti</div>
|
||
<div class="table-responsive">
|
||
<table id="tMix" class="table table-striped align-middle w-100 mx-table"></table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-xl-7" data-widget="mixmonths" data-title="Mescole consumate per mese">
|
||
<div class="chart-card">
|
||
<div class="chart-title" data-help="mx.mix">Mescole consumate per mese (stima)</div>
|
||
<div class="chart-hint">Kg di mescola = metri venduti × g/m della scheda di lavoro. Righe a chili divise tra le mescole.</div>
|
||
<div id="chMix" style="min-height:340px;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-12" data-widget="margin" data-title="Margine sul costo mescola">
|
||
<div class="chart-card">
|
||
<div class="chart-title" data-help="mx.margin">Margine sul costo mescola</div>
|
||
<div class="chart-hint mb-2">Prezzo di vendita meno il costo della sola mescola (g/m della scheda di lavoro × €/kg pagato). Clic su una matrice per il dettaglio.</div>
|
||
<div id="mgEmpty" class="margin-card" style="display:none;"></div>
|
||
<div class="row g-3" id="mgLists">
|
||
<div class="col-lg-6">
|
||
<h6 class="fw-bold text-danger mb-1"><i class="fa-solid fa-triangle-exclamation me-1"></i> Margine più basso</h6>
|
||
<div id="mgLow"></div>
|
||
</div>
|
||
<div class="col-lg-6">
|
||
<h6 class="fw-bold text-success mb-1"><i class="fa-solid fa-trophy me-1"></i> Più margine in euro</h6>
|
||
<div id="mgTop"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<?php include('include/footer.php'); ?>
|
||
</div>
|
||
|
||
<!-- DETTAGLIO MATRICE -->
|
||
<div class="modal fade" id="mxModal" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog modal-xl modal-fullscreen-lg-down modal-dialog-scrollable">
|
||
<div class="modal-content" style="border-radius:18px;">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title fw-bold"><i class="fa-solid fa-shapes me-2 text-primary"></i><span id="dmTitle">Matrice</span></h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
|
||
</div>
|
||
<div class="modal-body" id="dmBody" style="background:#f8fafc;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<?php include('jsinclude.php'); ?>
|
||
<script>
|
||
$(function() {
|
||
$('.sidebar-wrapper').hover(function() {
|
||
$('.wrapper.toggled').addClass('sidebar-hovered');
|
||
}, function() {
|
||
$('.wrapper').removeClass('sidebar-hovered');
|
||
});
|
||
});
|
||
</script>
|
||
<script>
|
||
(function() {
|
||
const STORE_KEY = 'zibo_sales_matrici_v1';
|
||
const MONTHS = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'];
|
||
const WARN = {
|
||
revisione: 'altra revisione',
|
||
mappa: 'abbinata a mano',
|
||
da_creare: 'non in anagrafica'
|
||
};
|
||
const PALETTE = ['#7c3aed', '#0ea5e9', '#f59e0b', '#10b981', '#ef4444', '#ec4899', '#14b8a6', '#6366f1', '#84cc16', '#f97316', '#94a3b8'];
|
||
const UOM_LABEL = {
|
||
m: 'metro',
|
||
kg: 'kg',
|
||
pz: 'pezzo',
|
||
altro: ''
|
||
};
|
||
const MIX_ST = {
|
||
pz: ['tb-pz', 'A pezzi: in attesa'],
|
||
gm: ['tb-gm', 'g/m mancante'],
|
||
gm_part: ['tb-gm', 'g/m incompleto'],
|
||
none: ['tb-gm', 'Senza mescole'],
|
||
altro: ['tb-altro', 'UM non gestita']
|
||
};
|
||
|
||
|
||
let state = {
|
||
p: '12m',
|
||
from: '',
|
||
to: '',
|
||
c: [],
|
||
t: 'all',
|
||
u: 'all',
|
||
mapU: 'm'
|
||
};
|
||
let customers = [];
|
||
let data = null;
|
||
let charts = {};
|
||
let tables = {};
|
||
let reqSeq = 0;
|
||
let detail = null;
|
||
|
||
/* ---------- UTILS ---------- */
|
||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
} [c]));
|
||
const fmtNum = (n, d) => (+n || 0).toLocaleString('it-IT', {
|
||
minimumFractionDigits: d || 0,
|
||
maximumFractionDigits: d || 0
|
||
});
|
||
const fmtEur = (n, d) => (+n || 0).toLocaleString('it-IT', {
|
||
style: 'currency',
|
||
currency: 'EUR',
|
||
minimumFractionDigits: d || 0,
|
||
maximumFractionDigits: d || 0
|
||
});
|
||
const fmtPrice = v => v === null || v === undefined ? '—' : fmtEur(v, Math.abs(v) >= 10 ? 2 : 3);
|
||
|
||
// numeri grandi leggibili: 12,5 t · 125 mila m · 1,2 mln m · 85 k€
|
||
function fmtShort(n, unit) {
|
||
n = +n || 0;
|
||
const a = Math.abs(n);
|
||
const nf = (v, d) => v.toLocaleString('it-IT', {
|
||
maximumFractionDigits: d
|
||
});
|
||
if (unit === '€') {
|
||
if (a >= 1e6) return nf(n / 1e6, 2) + ' M€';
|
||
if (a >= 1e4) return nf(n / 1e3, 1) + ' k€';
|
||
return fmtNum(n) + ' €';
|
||
}
|
||
if (unit === 'kg' && a >= 1e4) return nf(n / 1e3, a >= 1e5 ? 0 : 1) + ' t';
|
||
const u = unit ? ' ' + unit : '';
|
||
if (a >= 1e6) return nf(n / 1e6, 2) + ' mln' + u;
|
||
if (a >= 1e4) return nf(n / 1e3, 1) + ' mila' + u;
|
||
return fmtNum(n) + u;
|
||
}
|
||
const fmtEurShort = n => Math.abs(+n || 0) >= 1e4 ? fmtShort(n, '€') : fmtEur(n);
|
||
|
||
function fmtDate(v) {
|
||
if (!v) return '';
|
||
const p = String(v).substring(0, 10).split('-');
|
||
return `${p[2]}/${p[1]}/${p[0]}`;
|
||
}
|
||
const iso = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||
const qtyFmt = v => fmtNum(+v, +v % 1 ? 2 : 0);
|
||
|
||
function pctSpan(p) {
|
||
if (p === null || p === undefined || !isFinite(p)) return '<span class="text-muted">—</span>';
|
||
const x = Math.round(p * 10) / 10;
|
||
return `<span class="${x >= 0 ? 'delta-up' : 'delta-down'}">${x >= 0 ? '▲' : '▼'} ${Math.abs(x).toLocaleString('it-IT')}%</span>`;
|
||
}
|
||
|
||
// badge del tipo (M matrice, T tubo, R rettangolo, D diametro…) + avviso sul riconoscimento
|
||
function typeBadge(r) {
|
||
const cats = (data && data.cats) || {};
|
||
const w = WARN[r.type] ? ` <span class="tb tb-warn" title="Riconoscimento">${WARN[r.type]}</span>` : '';
|
||
return `<span class="tb tb-${esc(r.cat)}">${esc(cats[r.cat] || r.cat)}</span>${w}`;
|
||
}
|
||
|
||
function thumb(src, sz) {
|
||
const s = sz ? `style="width:${sz}px;height:${sz}px;"` : '';
|
||
return src ? `<img class="mx-thumb" ${s} src="${esc(src)}" loading="lazy" onerror="this.outerHTML='<div class="mx-thumb ph"><i class="fa-solid fa-shapes"></i></div>'">` :
|
||
`<div class="mx-thumb ph" ${s}><i class="fa-solid fa-shapes"></i></div>`;
|
||
}
|
||
|
||
function spark(vals) {
|
||
const pts = vals.map((v, i) => v === null ? null : [i, v]).filter(Boolean);
|
||
if (pts.length < 2) return '<svg class="spk" width="84" height="26"></svg>';
|
||
const ys = pts.map(p => p[1]);
|
||
const mn = Math.min(...ys),
|
||
mx = Math.max(...ys),
|
||
rg = mx - mn || 1;
|
||
const xy = p => [(p[0] / 11 * 80 + 2).toFixed(1), (22 - (p[1] - mn) / rg * 18 + 1).toFixed(1)];
|
||
const line = pts.map(p => xy(p).join(',')).join(' ');
|
||
const up = ys[ys.length - 1] >= ys[0];
|
||
const last = xy(pts[pts.length - 1]);
|
||
return `<svg class="spk" width="84" height="26"><polyline points="${line}" fill="none" stroke="${up ? '#16a34a' : '#dc2626'}" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/><circle cx="${last[0]}" cy="${last[1]}" r="2.6" fill="${up ? '#16a34a' : '#dc2626'}"/></svg>`;
|
||
}
|
||
|
||
function mixCell(r) {
|
||
if (MIX_ST[r.mix_status] && !(r.mix_status === 'gm_part' && r.kg > 0)) {
|
||
const m = MIX_ST[r.mix_status];
|
||
return `<span class="tb ${m[0]}">${m[1]}</span>`;
|
||
}
|
||
if (!r.id) return '<span class="text-muted">—</span>';
|
||
return `<b>${fmtShort(r.kg, 'kg')}</b>${r.mix_status === 'gm_part' ? ' <span class="tb tb-gm" title="Su qualche mescola manca il g/m">parziale</span>' : ''}<div class="small text-muted">${fmtNum(r.mix_n)} mescol${r.mix_n === 1 ? 'a' : 'e'}${r.gm_total ? ' · ' + fmtNum(r.gm_total, 1) + ' g/m' : ''}</div>`;
|
||
}
|
||
|
||
|
||
const DT_LANG = {
|
||
url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json'
|
||
};
|
||
const DT_DOM = "<'row mb-2'<'col-md-6'B><'col-md-6'f>>rt<'row mt-2'<'col-md-5'i><'col-md-7'p>>";
|
||
const xls = title => ({
|
||
dom: {
|
||
button: {
|
||
className: 'btn'
|
||
}
|
||
},
|
||
buttons: [{
|
||
extend: 'excelHtml5',
|
||
text: '<i class="fa-solid fa-file-excel me-1"></i> Excel',
|
||
className: 'btn btn-outline-success btn-sm',
|
||
title: title,
|
||
exportOptions: {
|
||
orthogonal: 'export'
|
||
}
|
||
}]
|
||
});
|
||
|
||
function makeTable(key, el, opts) {
|
||
if (tables[key]) {
|
||
tables[key].destroy();
|
||
$(el).empty();
|
||
}
|
||
tables[key] = $(el).DataTable(Object.assign({
|
||
pageLength: 25,
|
||
lengthMenu: [10, 25, 50, 100, 500],
|
||
dom: DT_DOM,
|
||
language: DT_LANG,
|
||
autoWidth: false
|
||
}, opts));
|
||
return tables[key];
|
||
}
|
||
|
||
function upsert(key, el, opts) {
|
||
if (charts[key]) {
|
||
charts[key].destroy();
|
||
delete charts[key];
|
||
}
|
||
charts[key] = new ApexCharts(document.getElementById(el), opts);
|
||
charts[key].render();
|
||
}
|
||
|
||
/* ---------- STATO ---------- */
|
||
function save() {
|
||
try {
|
||
localStorage.setItem(STORE_KEY, JSON.stringify(state));
|
||
} catch (e) {}
|
||
}
|
||
|
||
function restore() {
|
||
try {
|
||
const s = JSON.parse(localStorage.getItem(STORE_KEY) || '{}');
|
||
if (s && typeof s === 'object') state = Object.assign(state, s);
|
||
} catch (e) {}
|
||
const q = new URLSearchParams(location.search);
|
||
if (q.get('c')) state.c = q.get('c').split(',').filter(Boolean);
|
||
if (!['all', 'M', 'T', 'R', 'D', 'other', 'todo'].includes(state.t)) state.t = 'all';
|
||
}
|
||
|
||
function periodRange(p) {
|
||
const t = new Date();
|
||
const y = t.getFullYear();
|
||
const back = days => iso(new Date(t.getTime() - days * 864e5));
|
||
switch (p) {
|
||
case 'ytd':
|
||
return [y + '-01-01', iso(t)];
|
||
case 'ly':
|
||
return [(y - 1) + '-01-01', (y - 1) + '-12-31'];
|
||
case '24m':
|
||
return [back(730), iso(t)];
|
||
case '36m':
|
||
return [back(1095), iso(t)];
|
||
case 'all':
|
||
return ['2020-01-01', iso(t)];
|
||
case 'custom':
|
||
return [state.from, state.to];
|
||
default:
|
||
return [back(365), iso(t)];
|
||
}
|
||
}
|
||
|
||
function syncUi() {
|
||
if (state.p !== 'custom')[state.from, state.to] = periodRange(state.p);
|
||
$('#fPeriod .mxchip').each(function() {
|
||
$(this).toggleClass('active', $(this).data('p') === state.p);
|
||
});
|
||
$('#fFrom').val(state.from);
|
||
$('#fTo').val(state.to);
|
||
$('#fType .mxchip').each(function() {
|
||
$(this).toggleClass('active', $(this).data('t') === state.t);
|
||
});
|
||
$('#fUom .mxchip').each(function() {
|
||
$(this).toggleClass('active', $(this).data('u') === state.u);
|
||
});
|
||
$('#mapUom button').each(function() {
|
||
$(this).toggleClass('active', $(this).data('u') === state.mapU);
|
||
});
|
||
}
|
||
|
||
function rowsFiltered() {
|
||
return (data.rows || []).filter(r => {
|
||
if (['M', 'T', 'R', 'D'].includes(state.t) && r.cat !== state.t) return false;
|
||
if (state.t === 'other' && ['M', 'T', 'R', 'D'].includes(r.cat)) return false;
|
||
if (state.t === 'todo' && !['revisione', 'da_creare', 'altro'].includes(r.type)) return false;
|
||
if (state.u !== 'all' && r.kind !== state.u) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/* ---------- CARICAMENTO ---------- */
|
||
function loadFilters() {
|
||
return fetch('?action=filters').then(r => r.json()).then(d => {
|
||
customers = d.customers || [];
|
||
const $s = $('#fCustomers').empty();
|
||
customers.forEach(c => $s.append(new Option(`${c.name} (${c.code})`, c.code, false, state.c.includes(c.code))));
|
||
$s.trigger('change.select2');
|
||
const so = (d.sync || {}).sale_ord || {};
|
||
$('#syncPill').toggleClass('ok', so.last_status === 'ok').toggleClass('err', so.last_status === 'errore');
|
||
$('#syncText').text(so.last_run_at ? `Aggiornato il ${so.last_run_at}` : 'Dati Mago4');
|
||
});
|
||
}
|
||
|
||
function load() {
|
||
syncUi();
|
||
save();
|
||
const seq = ++reqSeq;
|
||
$('#content').css('opacity', .55);
|
||
const u = new URL(location.href);
|
||
if (state.c.length) u.searchParams.set('c', state.c.join(','));
|
||
else u.searchParams.delete('c');
|
||
history.replaceState(null, '', u.toString());
|
||
|
||
const qs = new URLSearchParams({
|
||
action: 'list',
|
||
from: state.from,
|
||
to: state.to,
|
||
c: state.c.join(',')
|
||
});
|
||
return fetch('?' + qs).then(r => r.json()).then(d => {
|
||
if (seq !== reqSeq) return;
|
||
$('#loading').hide();
|
||
$('#content').show().css('opacity', 1);
|
||
if (!d.success) {
|
||
$('#kpis').html(`<div class="alert alert-danger">${esc(d.message || 'Errore nel caricamento')}</div>`);
|
||
return;
|
||
}
|
||
data = d;
|
||
renderBanner();
|
||
renderKpis();
|
||
renderAll();
|
||
renderMix();
|
||
}).catch(e => {
|
||
console.error(e);
|
||
$('#loading').html('<div class="text-danger">Errore nel caricamento: ' + esc(e && e.message ? e.message : e) + '</div>');
|
||
});
|
||
}
|
||
|
||
function renderAll() {
|
||
renderMap();
|
||
renderMoves();
|
||
renderTable();
|
||
renderMargin();
|
||
}
|
||
|
||
function renderBanner() {
|
||
if (!state.c.length) return $('#onlyBanner').hide();
|
||
const names = state.c.map(c => (customers.find(x => x.code === c) || {}).name || c);
|
||
const one = state.c.length === 1;
|
||
$('#onlyBanner').show().html(`<i class="fa-solid fa-filter me-2"></i>Stai guardando solo cosa ordina${one ? '' : 'no'}: <b>${esc(names.slice(0, 4).join(', '))}${names.length > 4 ? ' e altri ' + (names.length - 4) : ''}</b>
|
||
${one ? `<a class="ms-2" href="sales_customer.php?c=${encodeURIComponent(state.c[0])}"><i class="fa-solid fa-address-card me-1"></i>Scheda cliente</a>` : ''}
|
||
<a href="#" class="ms-3" id="clearCust">Togli il filtro</a>`);
|
||
}
|
||
|
||
/* ---------- KPI ---------- */
|
||
function renderKpis() {
|
||
const k = data.kpi;
|
||
const pm = k.pq_m > 0 ? k.val_m / k.pq_m : null;
|
||
const pk = k.pq_kg > 0 ? k.val_kg / k.pq_kg : null;
|
||
const tot = k.linked_value + k.profile_value + k.unlinked_value || 1;
|
||
const pl = k.linked_value / tot * 100,
|
||
pp = k.profile_value / tot * 100,
|
||
pu = k.unlinked_value / tot * 100;
|
||
const card = (color, icon, lbl, big, sub, attrs, help) => `<div class="mxk ${attrs ? 'click' : ''}" style="--k:${color};" ${attrs || ''}>
|
||
<div class="ico"><i class="fa-solid ${icon}"></i></div><div class="lbl"${help ? ` data-help="${help}"` : ''}>${lbl}</div><div class="big">${big}</div><div class="sub">${sub}</div></div>`;
|
||
$('#kpis').html([
|
||
card('#7c3aed', 'fa-sack-dollar', 'Venduto con matrice', fmtEurShort(k.value),
|
||
`<b>${fmtNum(k.lines)}</b> righe · <b>${fmtNum(k.matrici)}</b> matrici<br><b>${fmtNum(k.clients)}</b> clienti`, '', 'mx.k.value'),
|
||
card('#0ea5e9', 'fa-ruler-horizontal', 'Venduto a metri', fmtShort(k.qty_m, 'm'),
|
||
`prezzo medio <b>${fmtPrice(pm)}/m</b>`, 'data-goto-u="m"', 'mx.k.qty'),
|
||
card('#f59e0b', 'fa-weight-hanging', 'Venduto a chili', fmtShort(k.qty_kg, 'kg'),
|
||
`prezzo medio <b>${fmtPrice(pk)}/kg</b>`, 'data-goto-u="kg"', 'mx.k.qty'),
|
||
card('#10b981', 'fa-flask', 'Mescola consumata (stima)', fmtShort(k.kg_mix, 'kg'),
|
||
`ancora da produrre: <b>${fmtShort(k.kg_open, 'kg')}</b><br>per ${fmtEurShort(k.open_value)} di ordini aperti`, '', 'mx.k.mix'),
|
||
card('#ec4899', 'fa-cubes', 'Venduto a pezzi', fmtNum(k.pz_lines) + ' righe',
|
||
`${fmtEurShort(k.pz_value)} · <span class="tb tb-pz">mescole in attesa</span><br>serve la conversione pezzi → metri`, 'data-goto-u="pz"', 'mx.k.pz'),
|
||
marginCard(card, k),
|
||
card('#64748b', 'fa-link', 'Copertura anagrafica', fmtNum(pl, 0) + '%',
|
||
`del venduto è collegato a una matrice<div class="mxbar"><i style="width:${pl}%;background:#22c55e"></i><i style="width:${pp}%;background:#a78bfa"></i><i style="width:${pu}%;background:#f97316"></i></div>
|
||
<span style="color:#7c3aed">tubi/rett./diam. ${fmtNum(pp, 0)}%</span> · <span style="color:#ea580c">da sistemare ${fmtNum(pu, 0)}%</span>${k.gm_missing_lines ? `<br><span class="tb tb-gm">${fmtNum(k.gm_missing_lines)} righe senza g/m</span>` : ''}`, 'data-goto-t="todo"', 'mx.k.cover')
|
||
].join(''));
|
||
}
|
||
|
||
function marginCard(card, k) {
|
||
if (!data.has_prices) return card('#16a34a', 'fa-scale-balanced', 'Margine sul grezzo', '—', 'Prezzi mescole non ancora disponibili:<br>serve il sync acquisti', '', 'mx.k.margin');
|
||
const pct = k.cost_cover_value > 0 ? (k.cost_cover_value - k.cost_value) / k.cost_cover_value * 100 : null;
|
||
const cov = k.value > 0 ? k.cost_cover_value / k.value * 100 : 0;
|
||
return card(pct !== null && pct < 50 ? '#dc2626' : '#16a34a', 'fa-scale-balanced', 'Margine sul grezzo', pct === null ? '—' : fmtNum(pct, 0) + '%',
|
||
`costo mescola <b>${fmtEurShort(k.cost_value)}</b><br>calcolato sul <b>${fmtNum(cov, 0)}%</b> del venduto${k.check_rows ? `<br><span class="tb tb-check">${fmtNum(k.check_rows)} con prezzo da verificare</span>` : ''}`, '', 'mx.k.margin');
|
||
}
|
||
|
||
function marginSpan(pct) {
|
||
if (pct === null || pct === undefined) return '<span class="text-muted">—</span>';
|
||
const c = pct >= 60 ? 'mg-hi' : pct >= 40 ? 'mg-mid' : 'mg-lo';
|
||
return `<span class="mg-pill ${c}">${fmtNum(pct, 0)}%</span>`;
|
||
}
|
||
|
||
const COST_ST = {
|
||
pz: 'a pezzi: in attesa',
|
||
gm: 'g/m mancante',
|
||
none: 'senza mescole',
|
||
price: 'prezzo mescola mancante',
|
||
altro: 'UM non gestita'
|
||
};
|
||
|
||
function costCell(r) {
|
||
if (r.cost === null || r.cost === undefined) {
|
||
if (!r.id) return '<span class="text-muted">—</span>';
|
||
return `<span class="tb ${r.cost_status === 'pz' ? 'tb-pz' : 'tb-gm'}">${COST_ST[r.cost_status] || '—'}</span>`;
|
||
}
|
||
const ul = UOM_LABEL[r.kind] || r.uom;
|
||
let b = '';
|
||
if (r.cost_status === 'partial') b += ` <span class="tb tb-gm" title="${esc((r.cost_missing || []).length ? 'Senza prezzo: ' + r.cost_missing.join(', ') : 'Manca il g/m di qualche mescola')}">parziale</span>`;
|
||
if ((r.cost_check || []).length) b += ` <span class="tb tb-check" title="Prezzo ${esc(r.cost_check.join(', '))} da verificare (conto lavoro?)">da verificare</span>`;
|
||
return `<b>${fmtPrice(r.cost)}</b><span class="small text-muted">/${ul}</span>${b}`;
|
||
}
|
||
|
||
function renderMargin() {
|
||
const rows = rowsFiltered().filter(r => r.margin_pct !== null && r.n > 0 && r.cost_status === 'ok');
|
||
if (!data.has_prices || !rows.length) {
|
||
$('#mgLists').hide();
|
||
$('#mgEmpty').show().html(!data.has_prices ?
|
||
'<b>Prezzi mescole non ancora disponibili.</b> Si attivano con il sync acquisti (mago_sync_mix_cli.php).' :
|
||
'<b>Nessuna matrice con costo completo</b> nei filtri scelti: servono scheda di lavoro con g/m e prezzo di tutte le mescole.');
|
||
return;
|
||
}
|
||
$('#mgEmpty').hide();
|
||
$('#mgLists').show();
|
||
const item = (r, right) => `<div class="mg-item" data-k="${esc(r.key)}">${thumb(r.photo, 36)}
|
||
<div style="min-width:0;flex:1;"><div class="nm text-truncate">${esc(r.name)} <span class="uom">${esc(r.uom)}</span></div>
|
||
<div class="pr">vendita ${fmtPrice(r.price)} · costo ${fmtPrice(r.cost)} /${UOM_LABEL[r.kind]}${(r.cost_check || []).length ? ' · <span class="tb tb-check">da verificare</span>' : ''}</div></div>
|
||
<div class="text-end">${right}</div></div>`;
|
||
const low = rows.slice().sort((a, b) => a.margin_pct - b.margin_pct).slice(0, 8);
|
||
const top = rows.map(r => Object.assign({}, r, {
|
||
mval: r.margin * (r.value / r.price)
|
||
})).sort((a, b) => b.mval - a.mval).slice(0, 8);
|
||
$('#mgLow').html(low.map(r => item(r, marginSpan(r.margin_pct))).join(''));
|
||
$('#mgTop').html(top.map(r => item(r, `<b>${fmtEurShort(r.mval)}</b><div>${marginSpan(r.margin_pct)}</div>`)).join(''));
|
||
}
|
||
|
||
/* ---------- MAPPA ---------- */
|
||
let mapRows = [];
|
||
|
||
function renderMap() {
|
||
const u = state.mapU;
|
||
mapRows = rowsFiltered().filter(r => r.kind === u && r.price > 0 && r.qty > 0).sort((a, b) => b.value - a.value).slice(0, 150);
|
||
const maxV = Math.max(1, ...mapRows.map(r => r.value));
|
||
const pts = mapRows.map(r => [Math.log10(r.qty), r.price, Math.max(4, Math.sqrt(r.value / maxV) * 40)]);
|
||
upsert('map', 'chMap', {
|
||
chart: {
|
||
type: 'bubble',
|
||
height: 380,
|
||
fontFamily: 'inherit',
|
||
toolbar: {
|
||
show: false
|
||
},
|
||
animations: {
|
||
speed: 350
|
||
},
|
||
events: {
|
||
dataPointSelection: (e, c, o) => {
|
||
const r = mapRows[o.dataPointIndex];
|
||
if (r) openDetail(r.key);
|
||
}
|
||
}
|
||
},
|
||
series: [{
|
||
name: 'Matrici',
|
||
data: pts
|
||
}],
|
||
colors: ['#7c3aed'],
|
||
fill: {
|
||
opacity: .55
|
||
},
|
||
stroke: {
|
||
width: 1,
|
||
colors: ['#5b21b6']
|
||
},
|
||
dataLabels: {
|
||
enabled: false
|
||
},
|
||
plotOptions: {
|
||
bubble: {
|
||
minBubbleRadius: 4,
|
||
maxBubbleRadius: 40
|
||
}
|
||
},
|
||
xaxis: {
|
||
type: 'numeric',
|
||
tickAmount: 6,
|
||
title: {
|
||
text: `Quantità venduta (${u === 'm' ? 'metri' : 'kg'}, scala log)`,
|
||
style: {
|
||
fontWeight: 600,
|
||
color: '#64748b'
|
||
}
|
||
},
|
||
labels: {
|
||
formatter: v => fmtShort(Math.pow(10, v))
|
||
}
|
||
},
|
||
yaxis: {
|
||
title: {
|
||
text: u === 'm' ? '€/metro' : '€/kg'
|
||
},
|
||
labels: {
|
||
formatter: v => fmtPrice(v)
|
||
}
|
||
},
|
||
grid: {
|
||
borderColor: '#eef2f7'
|
||
},
|
||
noData: {
|
||
text: 'Nessuna matrice venduta in questa unità'
|
||
},
|
||
tooltip: {
|
||
custom: ({
|
||
dataPointIndex
|
||
}) => {
|
||
const r = mapRows[dataPointIndex];
|
||
if (!r) return '';
|
||
return `<div style="padding:10px 12px;max-width:280px;">
|
||
<div style="font-weight:800;">${esc(r.name)}</div>
|
||
<div style="margin:2px 0 6px;">${typeBadge(r)}</div>
|
||
<div>Prezzo medio: <b>${fmtPrice(r.price)}/${UOM_LABEL[r.kind]}</b></div>
|
||
<div>Forbice: ${fmtPrice(r.pmin)} – ${fmtPrice(r.pmax)}</div>
|
||
<div>Venduto: <b>${qtyFmt(r.qty)} ${esc(r.uom)}</b> · ${fmtEur(r.value)}</div>
|
||
<div>${fmtNum(r.clients)} clienti · ${fmtNum(r.orders)} ordini</div></div>`;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ---------- PREZZI IN MOVIMENTO ---------- */
|
||
function renderMoves() {
|
||
const rows = rowsFiltered().filter(r => r.var !== null && r.kind !== 'altro');
|
||
const item = r => `<div class="mv-item" data-k="${esc(r.key)}">
|
||
${thumb(r.photo, 38)}
|
||
<div style="min-width:0;"><div class="nm text-truncate">${esc(r.name)} <span class="uom">${esc(r.uom)}</span></div>
|
||
<div class="pr">${fmtPrice(r.pricep)} → <b>${fmtPrice(r.price12)}</b> /${UOM_LABEL[r.kind]}</div></div>
|
||
<div class="pct">${pctSpan(r.var)}</div></div>`;
|
||
const up = rows.filter(r => r.var > 0.5).sort((a, b) => b.var-a.var).slice(0, 6);
|
||
const dn = rows.filter(r => r.var < -0.5).sort((a, b) => a.var-b.var).slice(0, 6);
|
||
const none = '<div class="text-muted small p-2">Nessuna variazione significativa</div>';
|
||
$('#mvUp').html(up.map(item).join('') || none);
|
||
$('#mvDown').html(dn.map(item).join('') || none);
|
||
}
|
||
|
||
/* ---------- CLASSIFICA ---------- */
|
||
function renderTable() {
|
||
const rows = rowsFiltered();
|
||
const maxSpread = Math.max(30, ...rows.map(r => r.spread || 0));
|
||
const sm = data.spark_months || [];
|
||
const sparkTitle = sm.length ? `${MONTHS[+sm[0].substring(5) - 1]} ${sm[0].substring(2, 4)} → ${MONTHS[+sm[11].substring(5) - 1]} ${sm[11].substring(2, 4)}` : '';
|
||
makeTable('mat', '#tMat', {
|
||
data: rows,
|
||
columns: [{
|
||
title: 'Matrice',
|
||
data: 'name',
|
||
render: (v, t, r) => {
|
||
if (t === 'export') return v;
|
||
if (t !== 'display') return v + ' ' + r.raws.join(' ');
|
||
const raw = r.raws.filter(x => x !== String(v).toUpperCase()).slice(0, 2).join(', ');
|
||
return `<div class="mx-name">${thumb(r.photo)}<div><b>${esc(v)}</b> ${typeBadge(r)}${raw ? `<div class="small text-muted">Mago: ${esc(raw)}</div>` : ''}</div></div>`;
|
||
}
|
||
}, {
|
||
title: 'UM',
|
||
data: 'uom',
|
||
className: 'text-center',
|
||
render: (v, t) => t === 'display' ? `<span class="uom">${esc(v)}</span>` : v
|
||
}, {
|
||
title: 'Clienti',
|
||
data: 'clients',
|
||
className: 'text-end'
|
||
}, {
|
||
title: 'Ordini',
|
||
data: 'orders',
|
||
className: 'text-end'
|
||
}, {
|
||
title: 'Quantità',
|
||
data: 'qty',
|
||
className: 'text-end text-nowrap',
|
||
render: (v, t, r) => t === 'display' ? `${qtyFmt(v)}${+r.open_qty ? `<div class="small text-muted" title="Ancora da consegnare">+${qtyFmt(r.open_qty)} da cons.</div>` : ''}` : +v
|
||
}, {
|
||
title: 'Valore',
|
||
data: 'value',
|
||
className: 'text-end amount',
|
||
render: (v, t) => t === 'display' ? fmtEur(v) : +v
|
||
}, {
|
||
title: 'Prezzo medio',
|
||
data: 'price',
|
||
className: 'text-end amount',
|
||
render: (v, t, r) => t === 'display' ? (v === null ? '—' : `<b>${fmtPrice(v)}</b><span class="small text-muted">/${UOM_LABEL[r.kind] || esc(r.uom)}</span>`) : (v === null ? '' : +v)
|
||
}, {
|
||
title: 'Forbice prezzi',
|
||
data: 'spread',
|
||
className: 'text-nowrap',
|
||
render: (v, t, r) => {
|
||
if (t === 'export') return r.pmin === null ? '' : `${fmtPrice(r.pmin)} - ${fmtPrice(r.pmax)}`;
|
||
if (t !== 'display') return v === null ? -1 : +v;
|
||
if (r.pmin === null) return '<span class="text-muted">—</span>';
|
||
const w = Math.min(100, (v || 0) / maxSpread * 100);
|
||
const col = v > 25 ? '#ef4444' : v > 10 ? '#f59e0b' : '#22c55e';
|
||
return `<span class="small">${fmtPrice(r.pmin)} – ${fmtPrice(r.pmax)}</span><span class="spread" title="Differenza tra prezzo minimo e massimo: ${fmtNum(v, 1)}%"><i style="width:${w}%;background:${col}"></i></span>`;
|
||
}
|
||
}, {
|
||
title: '12 mesi',
|
||
data: 'var',
|
||
className: 'text-nowrap',
|
||
render: (v, t, r) => {
|
||
if (t === 'export') return v === null ? '' : v / 100;
|
||
if (t !== 'display') return v === null ? -1e9 : +v;
|
||
return `<span title="${sparkTitle}">${spark(r.spark)}</span> ${pctSpan(v)}`;
|
||
}
|
||
}, {
|
||
title: 'Costo mescola',
|
||
data: 'cost',
|
||
className: 'text-end text-nowrap',
|
||
render: (v, t, r) => t === 'display' ? costCell(r) : (v === null ? (t === 'export' ? (COST_ST[r.cost_status] || '') : -1) : +v)
|
||
}, {
|
||
title: 'Margine',
|
||
data: 'margin_pct',
|
||
className: 'text-end',
|
||
render: (v, t) => t === 'display' ? marginSpan(v) : (v === null ? (t === 'export' ? '' : -1e9) : (t === 'export' ? v / 100 : +v))
|
||
}, {
|
||
title: 'Mescola',
|
||
data: 'kg',
|
||
className: 'text-end',
|
||
render: (v, t, r) => t === 'display' ? mixCell(r) : (t === 'export' ? (MIX_ST[r.mix_status] && !v ? MIX_ST[r.mix_status][1] : +v) : +v)
|
||
}, {
|
||
title: 'Ultimo',
|
||
data: 'last_date',
|
||
className: 'text-nowrap',
|
||
render: (v, t, r) => t === 'display' ? (v ? `${fmtDate(v)}<div class="small text-muted">${fmtPrice(r.last_price)}</div>` : '—') : (v || '')
|
||
}],
|
||
order: [
|
||
[5, 'desc']
|
||
],
|
||
buttons: xls('Matrici - prezzi ' + fmtDate(state.from) + ' - ' + fmtDate(state.to)),
|
||
createdRow: (tr, r) => $(tr).attr('data-k', r.key)
|
||
});
|
||
}
|
||
|
||
/* ---------- MESCOLE ---------- */
|
||
function renderMix() {
|
||
const ms = data.mescole || [];
|
||
const keys = [];
|
||
const s = new Date(data.from + 'T00:00:00'),
|
||
e = new Date(data.to + 'T00:00:00');
|
||
for (let d = new Date(s.getFullYear(), s.getMonth(), 1); d <= e; d.setMonth(d.getMonth() + 1)) keys.push(d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'));
|
||
const top = ms.slice(0, 8);
|
||
const series = top.map(m => ({
|
||
name: m.name,
|
||
data: keys.map(k => Math.round((m.months || {})[k] || 0))
|
||
}));
|
||
if (ms.length > 8) {
|
||
series.push({
|
||
name: 'Altre',
|
||
data: keys.map(k => Math.round(ms.slice(8).reduce((a, m) => a + ((m.months || {})[k] || 0), 0)))
|
||
});
|
||
}
|
||
upsert('mix', 'chMix', {
|
||
chart: {
|
||
type: 'bar',
|
||
stacked: true,
|
||
height: 340,
|
||
fontFamily: 'inherit',
|
||
toolbar: {
|
||
show: false
|
||
},
|
||
animations: {
|
||
speed: 350
|
||
}
|
||
},
|
||
series: series,
|
||
colors: PALETTE,
|
||
plotOptions: {
|
||
bar: {
|
||
columnWidth: '65%',
|
||
borderRadius: 2
|
||
}
|
||
},
|
||
dataLabels: {
|
||
enabled: false
|
||
},
|
||
xaxis: {
|
||
categories: keys.map(k => MONTHS[+k.substring(5) - 1] + ' ' + k.substring(2, 4)),
|
||
labels: {
|
||
rotate: -45,
|
||
hideOverlappingLabels: true
|
||
}
|
||
},
|
||
yaxis: {
|
||
labels: {
|
||
formatter: v => fmtShort(v, 'kg')
|
||
}
|
||
},
|
||
tooltip: {
|
||
shared: true,
|
||
intersect: false,
|
||
y: {
|
||
formatter: v => fmtNum(v) + ' kg'
|
||
}
|
||
},
|
||
legend: {
|
||
position: 'top',
|
||
horizontalAlign: 'left'
|
||
},
|
||
grid: {
|
||
borderColor: '#eef2f7'
|
||
},
|
||
noData: {
|
||
text: 'Nessuna mescola stimata: servono le schede di lavoro con i g/m'
|
||
}
|
||
});
|
||
|
||
makeTable('mix', '#tMix', {
|
||
data: ms,
|
||
pageLength: 10,
|
||
lengthMenu: [10, 25, 100],
|
||
columns: [{
|
||
title: 'Mescola',
|
||
data: 'name',
|
||
render: (v, t) => t === 'display' ? `<b>${esc(v)}</b>` : v
|
||
}, {
|
||
title: 'Venduto',
|
||
data: 'kg',
|
||
className: 'text-end amount',
|
||
render: (v, t) => t === 'display' ? fmtNum(v) + ' kg' : +v
|
||
}, {
|
||
title: 'Da produrre',
|
||
data: 'kg_open',
|
||
className: 'text-end amount',
|
||
render: (v, t) => t === 'display' ? (+v ? `<b style="color:#047857">${fmtNum(v)} kg</b>` : '<span class="text-muted">—</span>') : +v
|
||
}, {
|
||
title: 'Matrici',
|
||
data: 'matrici',
|
||
className: 'text-end'
|
||
}],
|
||
order: [
|
||
[1, 'desc']
|
||
],
|
||
buttons: xls('Fabbisogno mescole ' + fmtDate(state.from) + ' - ' + fmtDate(state.to))
|
||
});
|
||
$('#tMix tbody').css('cursor', 'default');
|
||
}
|
||
|
||
/* ---------- DETTAGLIO ---------- */
|
||
let dmTab = 'clients';
|
||
|
||
function openDetail(key) {
|
||
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('mxModal'));
|
||
$('#dmTitle').text('Carico…');
|
||
$('#dmBody').html('<div class="empty-state"><div class="spinner-border text-secondary"></div></div>');
|
||
modal.show();
|
||
const u = new URL(location.href);
|
||
u.searchParams.set('k', key);
|
||
history.replaceState(null, '', u.toString());
|
||
const qs = new URLSearchParams({
|
||
action: 'detail',
|
||
k: key,
|
||
from: state.from,
|
||
to: state.to,
|
||
c: state.c.join(',')
|
||
});
|
||
fetch('?' + qs).then(r => r.json()).then(d => {
|
||
if (!d.success) {
|
||
$('#dmTitle').text('Matrice');
|
||
$('#dmBody').html(`<div class="alert alert-warning">${esc(d.message || 'Errore')}</div>`);
|
||
return;
|
||
}
|
||
detail = d;
|
||
renderDetail(d);
|
||
});
|
||
}
|
||
|
||
function renderDetail(d) {
|
||
const h = d.head,
|
||
t = d.tot;
|
||
const ul = UOM_LABEL[h.kind] || h.uom;
|
||
$('#dmTitle').html(`${esc(h.name)} <span class="uom ms-1">${esc(h.uom)}</span>`);
|
||
const facts = [];
|
||
facts.push(`<div class="fact">${typeBadge(h)}</div>`);
|
||
if (h.raws.length) facts.push(`<div class="fact">In Mago: <b>${esc(h.raws.slice(0, 4).join(', '))}</b>${h.raws.length > 4 ? ' …' : ''}</div>`);
|
||
if (h.lines && h.lines.length) facts.push(`<div class="fact">Linee: ${h.lines.map(l => `<span class="lchip" style="background:${esc(l.color || '#e2e8f0')}33;border:1px solid ${esc(l.color || '#cbd5e1')}">${esc(l.name)}</span>`).join(' ')}</div>`);
|
||
if (h.ws) facts.push(`<div class="fact">Scheda di lavoro <b>n° ${esc(h.ws.number)}</b>${h.ws.rev ? ' rev. ' + esc(h.ws.rev) : ''}${h.ws.date ? ' del ' + fmtDate(h.ws.date) : ''}${h.ws.active ? '' : ' <span class="tb tb-gm">non attiva</span>'}</div>`);
|
||
else if (h.id) facts.push(`<div class="fact"><span class="tb tb-gm">Nessuna scheda di lavoro</span></div>`);
|
||
if (MIX_ST[h.mix_status]) facts.push(`<div class="fact"><span class="tb ${MIX_ST[h.mix_status][0]}">${MIX_ST[h.mix_status][1]}</span></div>`);
|
||
|
||
const k = (c, l, b, s) => `<div class="mxk" style="--k:${c};"><div class="lbl">${l}</div><div class="big" style="font-size:1.35rem;">${b}</div><div class="sub">${s || ''}</div></div>`;
|
||
const sp = t.pmin > 0 ? (t.pmax - t.pmin) / t.pmin * 100 : null;
|
||
|
||
$('#dmBody').html(`
|
||
<div class="dm-head mb-3">
|
||
${h.photo ? `<a href="${esc(h.photo)}" target="_blank"><img class="dm-photo" src="${esc(h.photo)}" onerror="this.style.display='none'"></a>` : ''}
|
||
<div style="flex:1;min-width:240px;">
|
||
<div class="dm-title">${esc(h.name)}</div>
|
||
<div class="dm-facts">${facts.join('')}</div>
|
||
</div>
|
||
</div>
|
||
<div class="mxk-grid mb-3" style="grid-template-columns:repeat(auto-fit,minmax(170px,1fr));">
|
||
${k('#7c3aed', 'Venduto', fmtEurShort(t.value), `${fmtNum(t.n)} righe · ${fmtNum(t.clients)} clienti`)}
|
||
${k('#0ea5e9', 'Quantità', qtyFmt(t.qty) + ' ' + esc(h.uom), t.open_qty ? `+${qtyFmt(t.open_qty)} da consegnare` : 'nessuna riga aperta')}
|
||
${k('#f59e0b', 'Prezzo medio', fmtPrice(t.price) + '<small class="text-muted fs-6">/' + ul + '</small>', '')}
|
||
${k(sp > 25 ? '#ef4444' : '#64748b', 'Forbice', t.pmin === null ? '—' : `${fmtPrice(t.pmin)} – ${fmtPrice(t.pmax)}`, sp !== null ? `differenza ${fmtNum(sp, 1)}% tra min e max` : '')}
|
||
${k('#10b981', 'Mescola (stima)', fmtShort(t.kg, 'kg'), t.open_kg ? `da produrre: <b>${fmtShort(t.open_kg, 'kg')}</b>` : '')}
|
||
${costKpis(d, k, ul)}
|
||
</div>
|
||
<div class="chart-card mb-3">
|
||
<div class="chart-title" data-help="mx.dprice">Prezzo nel tempo, cliente per cliente</div>
|
||
<div class="chart-hint">Ogni punto è una riga d'ordine; la linea scura è la media del mese, la linea rossa tratteggiata il costo della mescola. La distanza tra le due è il margine.</div>
|
||
<div id="chDetail" style="min-height:340px;"></div>
|
||
</div>
|
||
<div class="chart-card">
|
||
<div class="dm-tabs" id="dmTabs">
|
||
<button type="button" data-t="clients"><i class="fa-solid fa-users me-1"></i> Clienti <span class="badge bg-light text-dark">${fmtNum(d.clients.length)}</span></button>
|
||
<button type="button" data-t="mix"><i class="fa-solid fa-flask me-1"></i> Mescole <span class="badge bg-light text-dark">${fmtNum(d.mix.length)}</span></button>
|
||
<button type="button" data-t="lines"><i class="fa-solid fa-file-invoice me-1"></i> Righe d'ordine <span class="badge bg-light text-dark">${fmtNum(d.lines.length)}</span></button>
|
||
</div>
|
||
<div class="dm-pane" data-p="clients"><div class="table-responsive"><table id="tdClients" class="table table-striped align-middle w-100 mx-table"></table></div></div>
|
||
<div class="dm-pane" data-p="mix" style="display:none;">${mixPane(d)}</div>
|
||
<div class="dm-pane" data-p="lines" style="display:none;"><div class="table-responsive"><table id="tdLines" class="table table-striped align-middle w-100 mx-table"></table></div></div>
|
||
</div>`);
|
||
|
||
renderDetailChart(d);
|
||
renderDetailTables(d);
|
||
showDmTab(dmTab);
|
||
}
|
||
|
||
function costKpis(d, k, ul) {
|
||
const c = d.cost || {};
|
||
if (c.cost === null || c.cost === undefined) {
|
||
return k('#64748b', 'Costo mescola', '—', COST_ST[c.status] || 'non calcolabile');
|
||
}
|
||
const warn = [];
|
||
if (c.status === 'partial') warn.push(`<span class="tb tb-gm">parziale</span>`);
|
||
(c.check || []).forEach(x => warn.push(`<span class="tb tb-check">${esc(x)} da verificare</span>`));
|
||
const m = c.margin_pct;
|
||
return k('#dc2626', 'Costo mescola', fmtPrice(c.cost) + '<small class="text-muted fs-6">/' + ul + '</small>', `media ${c.price_months} mesi ${warn.join(' ')}`) +
|
||
k(m !== null && m < 40 ? '#dc2626' : '#16a34a', 'Margine sul grezzo', m === null ? '—' : fmtNum(m, 0) + '%',
|
||
m === null ? '' : `${fmtPrice(d.tot.price - c.cost)}/${ul} sul prezzo medio`);
|
||
}
|
||
|
||
function mixPane(d) {
|
||
const h = d.head;
|
||
const cost = d.cost || {};
|
||
const ul = UOM_LABEL[h.kind] || esc(h.uom);
|
||
if (h.kind === 'pz') {
|
||
return '<div class="alert alert-warning mb-0"><span class="tb tb-pz">A pezzi</span> Le righe vendute a pezzi aspettano la conversione pezzi → metri: le mescole non sono ancora stimate.</div>';
|
||
}
|
||
if (!d.mix.length) {
|
||
const msg = h.id ? 'Nessuna mescola collegata: completa la scheda di lavoro della matrice.' : 'Non collegata a una matrice dell'anagrafica.';
|
||
return '<div class="alert alert-light mb-0">' + msg + '</div>';
|
||
}
|
||
const src = h.mix_source === 'scheda' ? 'dall'ultima scheda di lavoro' : 'dall'anagrafica matrice (senza g/m)';
|
||
const rule = h.kind === 'm' ? 'Kg = metri × g/m ÷ 1000.' : 'I kg venduti si dividono in base alla quota.';
|
||
|
||
let html = '<div class="chart-hint mb-2">Mescole ' + src + '. ' + rule + '</div>';
|
||
html += '<div class="table-responsive"><table class="table table-striped align-middle mx-table mb-0"><thead><tr>';
|
||
html += '<th>Mescola</th><th class="text-end">g/m</th><th class="text-end">Quota</th><th class="text-end">€/kg</th>';
|
||
html += '<th class="text-end">Costo /' + ul + '</th><th class="text-end">Kg periodo</th><th class="text-end">Kg da produrre</th>';
|
||
html += '</tr></thead><tbody>';
|
||
d.mix.forEach(function(m) {
|
||
html += mixRow(m, cost);
|
||
});
|
||
if (cost.cost !== null && cost.cost !== undefined) {
|
||
html += '<tr class="table-light" style="cursor:default;"><td colspan="4" class="text-end fw-bold">Costo mescola totale</td>';
|
||
html += '<td class="text-end amount"><b>' + fmtPrice(cost.cost) + '</b></td><td colspan="2"></td></tr>';
|
||
}
|
||
html += '</tbody></table></div>';
|
||
html += '<div class="chart-hint mt-2">Costo = ' + (h.kind === 'm' ? 'g/m × €/kg ÷ 1000' : 'media dei €/kg pesata sulla quota') +
|
||
', prezzi medi degli ultimi ' + (cost.price_months || 3) + ' mesi pagati in Mago. Solo mescola: senza scarto, lavorazione, energia.</div>';
|
||
return html;
|
||
}
|
||
|
||
function mixRow(m, cost) {
|
||
const pt = (cost.parts || []).find(function(x) {
|
||
return x.id === m.id;
|
||
}) || {};
|
||
let eur;
|
||
if (pt.eur_kg !== null && pt.eur_kg !== undefined) {
|
||
eur = fmtNum(pt.eur_kg, 3);
|
||
if (pt.old) eur += ' <span class="tb tb-gm" title="Ultimo acquisto ' + fmtDate(pt.last_date) + '">vecchio</span>';
|
||
if (pt.check) eur += ' <span class="tb tb-check">da verificare</span>';
|
||
eur += '<div class="small text-muted">' + esc(pt.supplier || '') + '</div>';
|
||
} else {
|
||
eur = '<span class="tb tb-gm" title="Nessun acquisto abbinato: aggiungi il nome d'acquisto nella pagina mescole">senza prezzo</span>';
|
||
}
|
||
const gm = m.gm ? fmtNum(m.gm, 1) : '<span class="tb tb-gm">mancante</span>';
|
||
const part = (pt.part !== null && pt.part !== undefined) ? '<b>' + fmtPrice(pt.part) + '</b>' : '—';
|
||
const open = m.kg_open ? '<b style="color:#047857">' + fmtNum(m.kg_open) + ' kg</b>' : '—';
|
||
const out = m.out ? '<div class="small text-muted">' + esc(m.out) + '</div>' : '';
|
||
return '<tr style="cursor:default;">' +
|
||
'<td><b>' + esc(m.name) + '</b>' + out + '</td>' +
|
||
'<td class="text-end">' + gm + '</td>' +
|
||
'<td class="text-end">' + fmtNum(m.share, 1) + '%</td>' +
|
||
'<td class="text-end">' + eur + '</td>' +
|
||
'<td class="text-end amount">' + part + '</td>' +
|
||
'<td class="text-end amount">' + fmtNum(m.kg) + ' kg</td>' +
|
||
'<td class="text-end amount">' + open + '</td>' +
|
||
'</tr>';
|
||
}
|
||
|
||
function renderDetailChart(d) {
|
||
const ul = UOM_LABEL[d.head.kind] || d.head.uom;
|
||
const top = d.clients.slice(0, 8).map(c => c.code);
|
||
const by = {};
|
||
d.lines.forEach(l => {
|
||
if (l.price === null) return;
|
||
const k = top.includes(l.customer) ? l.customer : '_';
|
||
(by[k] = by[k] || []).push({
|
||
x: new Date(l.date + 'T12:00:00').getTime(),
|
||
y: l.price,
|
||
q: l.qty,
|
||
o: l.ord
|
||
});
|
||
});
|
||
const series = [];
|
||
top.forEach(c => {
|
||
if (by[c]) series.push({
|
||
name: (d.clients.find(x => x.code === c) || {}).name || c,
|
||
type: 'scatter',
|
||
data: by[c]
|
||
});
|
||
});
|
||
if (by._) series.push({
|
||
name: 'Altri clienti',
|
||
type: 'scatter',
|
||
data: by._
|
||
});
|
||
const nPts = series.length;
|
||
series.push({
|
||
name: 'Media mese',
|
||
type: 'line',
|
||
data: d.months.filter(m => m.price !== null).map(m => ({
|
||
x: new Date(m.ym + '-15T12:00:00').getTime(),
|
||
y: m.price
|
||
}))
|
||
});
|
||
const cm = ((d.cost || {}).months || []);
|
||
if (cm.length) series.push({
|
||
name: 'Costo mescola',
|
||
type: 'line',
|
||
data: cm.map(m => ({
|
||
x: new Date(m.ym + '-15T12:00:00').getTime(),
|
||
y: m.cost,
|
||
c: 1
|
||
}))
|
||
});
|
||
const n = nPts + 1; // indice+1 della linea "Media mese"
|
||
const isLine = i => i >= nPts;
|
||
upsert('detail', 'chDetail', {
|
||
chart: {
|
||
type: 'line',
|
||
height: 340,
|
||
fontFamily: 'inherit',
|
||
toolbar: {
|
||
show: false
|
||
},
|
||
zoom: {
|
||
enabled: false
|
||
},
|
||
animations: {
|
||
speed: 300
|
||
}
|
||
},
|
||
series: series,
|
||
colors: series.map((s, i) => i === n - 1 ? '#1f2d3d' : i === n ? '#dc2626' : PALETTE[i % PALETTE.length]),
|
||
stroke: {
|
||
width: series.map((s, i) => isLine(i) ? 2.5 : 0),
|
||
curve: series.map((s, i) => i === n ? 'stepline' : 'straight'),
|
||
dashArray: series.map((s, i) => i === n ? 6 : 0)
|
||
},
|
||
markers: {
|
||
size: series.map((s, i) => isLine(i) ? (i === n ? 0 : 3) : 7),
|
||
strokeWidth: 1,
|
||
strokeColors: '#fff',
|
||
hover: {
|
||
sizeOffset: 2
|
||
}
|
||
},
|
||
fill: {
|
||
opacity: series.map((s, i) => isLine(i) ? 1 : .8)
|
||
},
|
||
xaxis: {
|
||
type: 'datetime',
|
||
labels: {
|
||
datetimeUTC: false,
|
||
format: 'MMM yy'
|
||
}
|
||
},
|
||
yaxis: {
|
||
labels: {
|
||
formatter: v => fmtPrice(v)
|
||
},
|
||
title: {
|
||
text: '€/' + ul
|
||
}
|
||
},
|
||
legend: {
|
||
position: 'top',
|
||
horizontalAlign: 'left'
|
||
},
|
||
grid: {
|
||
borderColor: '#eef2f7'
|
||
},
|
||
tooltip: {
|
||
shared: false,
|
||
intersect: true,
|
||
custom: ({
|
||
seriesIndex,
|
||
dataPointIndex,
|
||
w
|
||
}) => {
|
||
const p = w.config.series[seriesIndex].data[dataPointIndex];
|
||
if (!p) return '';
|
||
const dt = new Date(p.x);
|
||
if (seriesIndex === n) return `<div style="padding:8px 12px;"><div style="font-weight:700;">Costo mescola ${MONTHS[dt.getMonth()]} ${dt.getFullYear()}</div><div><b>${fmtPrice(p.y)}/${ul}</b></div></div>`;
|
||
const lbl = seriesIndex === n - 1 ? `Media ${MONTHS[dt.getMonth()]} ${dt.getFullYear()}` : `${esc(w.config.series[seriesIndex].name)}<br><span style="color:#64748b">${fmtDate(iso(dt))} · ordine ${esc(p.o)}</span>`;
|
||
return `<div style="padding:8px 12px;"><div style="font-weight:700;">${lbl}</div><div><b>${fmtPrice(p.y)}/${ul}</b>${p.q ? ' · ' + qtyFmt(p.q) + ' ' + esc(d.head.uom) : ''}</div></div>`;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderDetailTables(d) {
|
||
const ul = UOM_LABEL[d.head.kind] || d.head.uom;
|
||
const nm = d.head.name;
|
||
makeTable('dClients', '#tdClients', {
|
||
data: d.clients,
|
||
pageLength: 10,
|
||
columns: [{
|
||
title: 'Cliente',
|
||
data: 'name',
|
||
render: (v, t, r) => t === 'display' ? `<b>${esc(v)}</b> <a href="sales_customer.php?c=${encodeURIComponent(r.code)}" title="Scheda cliente" onclick="event.stopPropagation()"><i class="fa-solid fa-address-card text-success"></i></a><div class="small text-muted">${esc(r.code)}</div>` : v
|
||
}, {
|
||
title: 'Ordini',
|
||
data: 'n',
|
||
className: 'text-end'
|
||
}, {
|
||
title: 'Quantità',
|
||
data: 'qty',
|
||
className: 'text-end text-nowrap',
|
||
render: (v, t, r) => t === 'display' ? qtyFmt(v) + (+r.open_qty ? `<div class="small text-muted">+${qtyFmt(r.open_qty)} da cons.</div>` : '') : +v
|
||
}, {
|
||
title: 'Valore',
|
||
data: 'value',
|
||
className: 'text-end amount',
|
||
render: (v, t) => t === 'display' ? fmtEur(v) : +v
|
||
}, {
|
||
title: 'Prezzo medio',
|
||
data: 'price',
|
||
className: 'text-end amount',
|
||
render: (v, t) => t === 'display' ? `<b>${fmtPrice(v)}</b><span class="small text-muted">/${ul}</span>` : (v === null ? '' : +v)
|
||
}, {
|
||
title: 'Rispetto alla media',
|
||
data: 'dev',
|
||
className: 'text-end',
|
||
render: (v, t) => {
|
||
if (t !== 'display') return v === null ? (t === 'export' ? '' : -1e9) : (t === 'export' ? v / 100 : +v);
|
||
if (v === null) return '—';
|
||
const c = v > 2 ? 'hi' : v < -2 ? 'lo' : 'eq';
|
||
return `<span class="dev-pill ${c}" title="${c === 'lo' ? 'Paga meno della media' : c === 'hi' ? 'Paga più della media' : 'In linea con la media'}">${v > 0 ? '+' : ''}${fmtNum(v, 1)}%</span>`;
|
||
}
|
||
}, {
|
||
title: 'Primo → ultimo prezzo',
|
||
data: 'last_price',
|
||
className: 'text-nowrap',
|
||
render: (v, t, r) => t === 'display' ? `${fmtPrice(r.first_price)} → <b>${fmtPrice(v)}</b><div class="small text-muted">${fmtDate(r.first_date)} → ${fmtDate(r.last_date)}</div>` : (v === null ? '' : +v)
|
||
}, {
|
||
title: '12 mesi',
|
||
data: 'var',
|
||
className: 'text-end',
|
||
render: (v, t) => t === 'display' ? pctSpan(v) : (v === null ? (t === 'export' ? '' : -1e9) : (t === 'export' ? v / 100 : +v))
|
||
}, {
|
||
title: 'Margine',
|
||
data: 'margin_pct',
|
||
className: 'text-end',
|
||
render: (v, t, r) => t === 'display' ? (v === null ? '<span class="text-muted">—</span>' : `${marginSpan(v)}<div class="small text-muted" title="Margine sull'ultimo prezzo">ultimo ${r.last_margin_pct === null ? '—' : fmtNum(r.last_margin_pct, 0) + '%'}</div>`) : (v === null ? (t === 'export' ? '' : -1e9) : (t === 'export' ? v / 100 : +v))
|
||
}],
|
||
order: [
|
||
[3, 'desc']
|
||
],
|
||
buttons: xls(`Clienti - ${nm}`)
|
||
});
|
||
makeTable('dLines', '#tdLines', {
|
||
data: d.lines,
|
||
pageLength: 10,
|
||
columns: [{
|
||
title: 'Data',
|
||
data: 'date',
|
||
render: (v, t) => t === 'display' ? fmtDate(v) : v
|
||
}, {
|
||
title: 'Ordine',
|
||
data: 'ord',
|
||
render: (v, t) => t === 'display' ? `<a class="ord-link" href="sales_orders_stats.php?q=${encodeURIComponent(v)}" target="_blank">${esc(v)}</a>` : v
|
||
}, {
|
||
title: 'Cliente',
|
||
data: 'cname'
|
||
}, {
|
||
title: 'Articolo',
|
||
data: 'item',
|
||
render: (v, t, r) => t === 'display' ? `<div class="fw-semibold">${esc(v)}</div><div class="small text-muted">${esc(r.description)}${r.raw ? ' · ' + esc(r.raw) : ''}</div>` : v
|
||
}, {
|
||
title: 'Quantità',
|
||
data: 'qty',
|
||
className: 'text-end text-nowrap',
|
||
render: (v, t, r) => t === 'display' ? qtyFmt(v) + (+r.open ? `<div class="small text-muted">${qtyFmt(r.open)} da cons.</div>` : '') : +v
|
||
}, {
|
||
title: 'Prezzo',
|
||
data: 'price',
|
||
className: 'text-end amount',
|
||
render: (v, t) => t === 'display' ? fmtPrice(v) : (v === null ? '' : +v)
|
||
}, {
|
||
title: 'Valore',
|
||
data: 'value',
|
||
className: 'text-end amount',
|
||
render: (v, t) => t === 'display' ? fmtEur(v, 2) : +v
|
||
}, {
|
||
title: 'Kg mescola',
|
||
data: 'kg',
|
||
className: 'text-end',
|
||
render: (v, t) => t === 'display' ? (+v ? fmtNum(v, 1) : '—') : +v
|
||
}],
|
||
order: [
|
||
[0, 'desc']
|
||
],
|
||
buttons: xls(`Righe ordine - ${nm}`)
|
||
});
|
||
}
|
||
|
||
function showDmTab(t) {
|
||
dmTab = t;
|
||
$('#dmTabs button').removeClass('active').filter(`[data-t="${t}"]`).addClass('active');
|
||
$('.dm-pane').hide().filter(`[data-p="${t}"]`).show();
|
||
try {
|
||
$.fn.dataTable.tables({
|
||
visible: true,
|
||
api: true
|
||
}).columns.adjust();
|
||
} catch (e) {}
|
||
}
|
||
|
||
|
||
/* ---------- EVENTI ---------- */
|
||
$(function() {
|
||
restore();
|
||
syncUi();
|
||
|
||
$('#fCustomers').select2({
|
||
theme: 'bootstrap-5',
|
||
placeholder: 'Tutti i clienti',
|
||
allowClear: true,
|
||
width: '100%',
|
||
closeOnSelect: false
|
||
})
|
||
.on('change', function() {
|
||
state.c = ($(this).val() || []);
|
||
clearTimeout(window._mxT);
|
||
window._mxT = setTimeout(load, 400);
|
||
});
|
||
|
||
$('#fPeriod').on('click', '.mxchip', function() {
|
||
state.p = $(this).data('p');
|
||
load();
|
||
});
|
||
$('#fFrom, #fTo').on('change', function() {
|
||
const f = $('#fFrom').val(),
|
||
t = $('#fTo').val();
|
||
if (!f || !t) return;
|
||
state.p = 'custom';
|
||
state.from = f;
|
||
state.to = t;
|
||
load();
|
||
});
|
||
$('#fType').on('click', '.mxchip', function() {
|
||
state.t = $(this).data('t');
|
||
syncUi();
|
||
save();
|
||
renderAll();
|
||
});
|
||
$('#fUom').on('click', '.mxchip', function() {
|
||
state.u = $(this).data('u');
|
||
if (state.u === 'm' || state.u === 'kg') state.mapU = state.u;
|
||
syncUi();
|
||
save();
|
||
renderAll();
|
||
});
|
||
$('#mapUom').on('click', 'button', function() {
|
||
state.mapU = $(this).data('u');
|
||
syncUi();
|
||
save();
|
||
renderMap();
|
||
});
|
||
$('#btnReset').on('click', function() {
|
||
state = {
|
||
p: '12m',
|
||
from: '',
|
||
to: '',
|
||
c: [],
|
||
t: 'all',
|
||
u: 'all',
|
||
mapU: 'm'
|
||
};
|
||
$('#fCustomers').val(null).trigger('change.select2');
|
||
load();
|
||
});
|
||
$('#kpis').on('click', '[data-goto-u]', function() {
|
||
$(`#fUom .mxchip[data-u="${$(this).data('goto-u')}"]`).trigger('click');
|
||
document.getElementById('tMat').scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'start'
|
||
});
|
||
}).on('click', '[data-goto-t]', function() {
|
||
$(`#fType .mxchip[data-t="${$(this).data('goto-t')}"]`).trigger('click');
|
||
document.getElementById('tMat').scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'start'
|
||
});
|
||
});
|
||
$('#onlyBanner').on('click', '#clearCust', function(e) {
|
||
e.preventDefault();
|
||
$('#fCustomers').val(null).trigger('change');
|
||
});
|
||
|
||
$('#tMat').on('click', 'tbody tr[data-k]', function() {
|
||
openDetail($(this).attr('data-k'));
|
||
});
|
||
$('#mgLow, #mgTop').on('click', '.mg-item', function() {
|
||
openDetail($(this).data('k'));
|
||
});
|
||
$('#mvUp, #mvDown').on('click', '.mv-item', function() {
|
||
openDetail($(this).data('k'));
|
||
});
|
||
$('#dmBody').on('click', '#dmTabs button', function() {
|
||
showDmTab($(this).data('t'));
|
||
});
|
||
document.getElementById('mxModal').addEventListener('hidden.bs.modal', () => {
|
||
const u = new URL(location.href);
|
||
u.searchParams.delete('k');
|
||
history.replaceState(null, '', u.toString());
|
||
if (charts.detail) {
|
||
charts.detail.destroy();
|
||
delete charts.detail;
|
||
}
|
||
});
|
||
|
||
loadFilters().catch(e => console.error('Filtri non caricati', e)).then(load).then(() => {
|
||
const k = new URLSearchParams(location.search).get('k');
|
||
if (k) openDetail(k);
|
||
});
|
||
});
|
||
})();
|
||
</script>
|
||
<script src="sales_help.js?v=5"></script>
|
||
<script src="ui_layout.js?v=1"></script>
|
||
</body>
|
||
|
||
</html><?php
|
||
ob_start();
|
||
ini_set('display_errors', 1);
|
||
error_reporting(E_ALL);
|
||
|
||
include('include/headscript.php');
|
||
require_once __DIR__ . '/class/MagoSalesStats.php';
|
||
require_once __DIR__ . '/class/MagoMatriceStats.php';
|
||
require_once __DIR__ . '/class/UiPrefs.php';
|
||
|
||
$db = DBHandlerSelect::getInstance();
|
||
$pdo = $db->getConnection();
|
||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||
|
||
// Permesso specifico: prezzi di vendita per matrice e cliente (dati riservati)
|
||
if (!userCan('sales.matrici.view')) {
|
||
if (isset($_GET['action'])) {
|
||
MagoSalesStats::jsonOut(['success' => 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;
|
||
}
|
||
$canDeliveries = userCan('sales.deliveries.view');
|
||
$canQuote = userCan('sales.quote.view');
|
||
|
||
/* ============================================================
|
||
* AJAX
|
||
* ============================================================ */
|
||
function mx_date($v, $def)
|
||
{
|
||
$v = (string)$v;
|
||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) ? $v : $def;
|
||
}
|
||
function mx_customers()
|
||
{
|
||
$c = trim((string)($_GET['c'] ?? ''));
|
||
return $c === '' ? [] : array_slice(array_values(array_filter(array_map('trim', explode(',', $c)))), 0, 200);
|
||
}
|
||
|
||
$action = $_GET['action'] ?? '';
|
||
|
||
if ($action === 'filters') {
|
||
MagoSalesStats::jsonOut(MagoSalesStats::filterOptions($pdo));
|
||
}
|
||
|
||
if ($action === 'list' || $action === 'detail') {
|
||
try {
|
||
$from = mx_date($_GET['from'] ?? '', date('Y-m-d', strtotime('-365 days')));
|
||
$to = mx_date($_GET['to'] ?? '', date('Y-m-d'));
|
||
if ($from < MagoMatriceStats::SINCE) $from = MagoMatriceStats::SINCE;
|
||
if ($to < $from) $to = $from;
|
||
if ($action === 'list') {
|
||
MagoSalesStats::jsonOut(MagoMatriceStats::buildList($pdo, $from, $to, mx_customers()));
|
||
}
|
||
MagoSalesStats::jsonOut(MagoMatriceStats::buildDetail($pdo, (string)($_GET['k'] ?? ''), $from, $to, mx_customers()));
|
||
} catch (Throwable $e) {
|
||
MagoSalesStats::jsonOut(['success' => false, 'message' => $e->getMessage()]);
|
||
}
|
||
}
|
||
?>
|
||
<!doctype html>
|
||
<html lang="it">
|
||
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
|
||
<?php include('cssinclude.php'); ?>
|
||
<title>Matrici: prezzi e mescole - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
|
||
|
||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||
<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>
|
||
<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>
|
||
<script src="https://cdn.jsdelivr.net/npm/apexcharts@3.49.1"></script>
|
||
|
||
<style>
|
||
:root {
|
||
--c-ink: #1f2d3d;
|
||
--c-muted: #64748b;
|
||
--c-line: #e2e8f0;
|
||
--mx: #7c3aed;
|
||
}
|
||
|
||
body {
|
||
font-size: 1.02rem;
|
||
background: #f8fafc;
|
||
}
|
||
|
||
.card {
|
||
border-radius: 16px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, .08);
|
||
}
|
||
|
||
.back-dashboard {
|
||
background: #b9ebc7 !important;
|
||
color: var(--c-ink) !important;
|
||
border-radius: 10px;
|
||
font-weight: 600;
|
||
padding: 10px 18px;
|
||
}
|
||
|
||
.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, #7c3aed, #c026d3);
|
||
color: #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 1.5rem;
|
||
box-shadow: 0 6px 16px rgba(124, 58, 237, .28);
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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.active {
|
||
background: #fff;
|
||
color: #198754;
|
||
border-color: var(--c-line);
|
||
border-bottom-color: #fff;
|
||
}
|
||
|
||
|
||
.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);
|
||
}
|
||
|
||
.flabel {
|
||
font-weight: 700;
|
||
font-size: .8rem;
|
||
color: var(--c-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: .4px;
|
||
margin-bottom: 4px;
|
||
display: block;
|
||
}
|
||
|
||
.mxrow {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
|
||
.mxchip {
|
||
border: 1.5px solid var(--c-line);
|
||
background: #fff;
|
||
color: var(--c-ink);
|
||
border-radius: 999px;
|
||
padding: 8px 15px;
|
||
font-weight: 700;
|
||
font-size: .92rem;
|
||
cursor: pointer;
|
||
min-height: 42px;
|
||
}
|
||
|
||
.mxchip.active {
|
||
background: var(--mx);
|
||
border-color: var(--mx);
|
||
color: #fff;
|
||
}
|
||
|
||
.mxchip .n {
|
||
font-size: .75rem;
|
||
opacity: .75;
|
||
margin-left: 4px;
|
||
}
|
||
|
||
.mx-date {
|
||
border-radius: 10px;
|
||
min-height: 42px;
|
||
max-width: 165px;
|
||
}
|
||
|
||
.select2-container--bootstrap-5 .select2-selection {
|
||
border-radius: 10px;
|
||
min-height: 42px;
|
||
}
|
||
|
||
.only-banner {
|
||
background: #f5f3ff;
|
||
border: 1px solid #ddd6fe;
|
||
color: #4c1d95;
|
||
border-radius: 12px;
|
||
padding: 10px 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* KPI */
|
||
.mxk-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||
gap: 14px;
|
||
}
|
||
|
||
@media (min-width: 1400px) {
|
||
.mxk-grid {
|
||
grid-template-columns: repeat(7, 1fr);
|
||
}
|
||
}
|
||
|
||
.mg-pill {
|
||
display: inline-block;
|
||
font-weight: 800;
|
||
font-size: .8rem;
|
||
border-radius: 999px;
|
||
padding: 2px 9px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.mg-hi {
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
}
|
||
|
||
.mg-mid {
|
||
background: #fef9c3;
|
||
color: #854d0e;
|
||
}
|
||
|
||
.mg-lo {
|
||
background: #fee2e2;
|
||
color: #991b1b;
|
||
}
|
||
|
||
.tb-check {
|
||
background: #ffedd5;
|
||
color: #9a3412;
|
||
}
|
||
|
||
.mg-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 10px;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
border: 1px solid transparent;
|
||
}
|
||
|
||
.mg-item:hover {
|
||
background: #f8fafc;
|
||
border-color: var(--c-line);
|
||
}
|
||
|
||
.mg-item .nm {
|
||
font-weight: 700;
|
||
color: var(--c-ink);
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.mg-item .pr {
|
||
font-size: .8rem;
|
||
color: var(--c-muted);
|
||
}
|
||
|
||
.mxk {
|
||
background: color-mix(in srgb, var(--k) 11%, #fff);
|
||
border: 1px solid color-mix(in srgb, var(--k) 26%, #fff);
|
||
border-radius: 16px;
|
||
padding: 15px 16px;
|
||
position: relative;
|
||
}
|
||
|
||
.mxk .lbl {
|
||
font-size: .76rem;
|
||
font-weight: 800;
|
||
text-transform: uppercase;
|
||
letter-spacing: .5px;
|
||
color: color-mix(in srgb, var(--k) 70%, #1f2d3d);
|
||
padding-right: 38px;
|
||
}
|
||
|
||
.mxk .ico {
|
||
position: absolute;
|
||
right: 12px;
|
||
top: 12px;
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
color: var(--k);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.mxk .big {
|
||
font-size: 1.6rem;
|
||
font-weight: 800;
|
||
color: var(--c-ink);
|
||
line-height: 1.15;
|
||
margin-top: 4px;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.mxk .sub {
|
||
font-size: .83rem;
|
||
color: var(--c-muted);
|
||
margin-top: 3px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.mxk .sub b {
|
||
color: var(--c-ink);
|
||
}
|
||
|
||
.mxk.click {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.mxbar {
|
||
height: 7px;
|
||
border-radius: 99px;
|
||
background: #eef2f7;
|
||
overflow: hidden;
|
||
display: flex;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.mxbar i {
|
||
display: block;
|
||
height: 100%;
|
||
}
|
||
|
||
/* card */
|
||
.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);
|
||
}
|
||
|
||
|
||
.seg {
|
||
display: inline-flex;
|
||
background: #f1f5f9;
|
||
border-radius: 12px;
|
||
padding: 4px;
|
||
gap: 4px;
|
||
}
|
||
|
||
.seg button {
|
||
border: 0;
|
||
background: transparent;
|
||
padding: 7px 14px;
|
||
border-radius: 9px;
|
||
font-weight: 700;
|
||
color: var(--c-muted);
|
||
}
|
||
|
||
.seg button.active {
|
||
background: #fff;
|
||
color: var(--c-ink);
|
||
box-shadow: 0 2px 6px rgba(15, 23, 42, .1);
|
||
}
|
||
|
||
/* prezzi in movimento */
|
||
.mv-col h6 {
|
||
font-weight: 800;
|
||
font-size: .85rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: .4px;
|
||
}
|
||
|
||
.mv-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 9px 10px;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
border: 1px solid transparent;
|
||
}
|
||
|
||
.mv-item:hover {
|
||
background: #f8fafc;
|
||
border-color: var(--c-line);
|
||
}
|
||
|
||
.mv-item .nm {
|
||
font-weight: 700;
|
||
color: var(--c-ink);
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.mv-item .pr {
|
||
font-size: .8rem;
|
||
color: var(--c-muted);
|
||
}
|
||
|
||
.mv-item .pct {
|
||
margin-left: auto;
|
||
font-weight: 800;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* tabelle */
|
||
.mx-table thead th {
|
||
background: #ede9fe;
|
||
color: var(--c-ink);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.mx-table tbody tr {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.amount {
|
||
font-variant-numeric: tabular-nums;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.mx-thumb {
|
||
width: 42px;
|
||
height: 42px;
|
||
border-radius: 10px;
|
||
object-fit: cover;
|
||
background: #f1f5f9;
|
||
border: 1px solid var(--c-line);
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.mx-thumb.ph {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #cbd5e1;
|
||
}
|
||
|
||
.mx-name {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.mx-name b {
|
||
color: var(--c-ink);
|
||
}
|
||
|
||
.tb {
|
||
display: inline-block;
|
||
font-size: .68rem;
|
||
font-weight: 800;
|
||
border-radius: 999px;
|
||
padding: 2px 8px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.tb-esatta {
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
}
|
||
|
||
.tb-revisione {
|
||
background: #fef9c3;
|
||
color: #854d0e;
|
||
}
|
||
|
||
.tb-mappa {
|
||
background: #e0f2fe;
|
||
color: #075985;
|
||
}
|
||
|
||
.tb-da_creare {
|
||
background: #ffedd5;
|
||
color: #9a3412;
|
||
}
|
||
|
||
.tb-profilo {
|
||
background: #ede9fe;
|
||
color: #5b21b6;
|
||
}
|
||
|
||
.tb-altro {
|
||
background: #e2e8f0;
|
||
color: #334155;
|
||
}
|
||
|
||
.tb-M {
|
||
background: #ede9fe;
|
||
color: #5b21b6;
|
||
}
|
||
|
||
.tb-T {
|
||
background: #e0f2fe;
|
||
color: #075985;
|
||
}
|
||
|
||
.tb-R {
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
}
|
||
|
||
.tb-D {
|
||
background: #fce7f3;
|
||
color: #9d174d;
|
||
}
|
||
|
||
.tb-Q {
|
||
background: #ccfbf1;
|
||
color: #115e59;
|
||
}
|
||
|
||
.tb-S {
|
||
background: #fef3c7;
|
||
color: #92400e;
|
||
}
|
||
|
||
.tb-X {
|
||
background: #e2e8f0;
|
||
color: #334155;
|
||
}
|
||
|
||
.tb-warn {
|
||
background: #fff7ed;
|
||
color: #c2410c;
|
||
border: 1px solid #fed7aa;
|
||
}
|
||
|
||
.tb-pz {
|
||
background: #fef3c7;
|
||
color: #92400e;
|
||
}
|
||
|
||
.tb-gm {
|
||
background: #fee2e2;
|
||
color: #991b1b;
|
||
}
|
||
|
||
.uom {
|
||
display: inline-block;
|
||
font-weight: 800;
|
||
font-size: .75rem;
|
||
border-radius: 6px;
|
||
padding: 2px 7px;
|
||
background: #f1f5f9;
|
||
color: #334155;
|
||
}
|
||
|
||
.spread {
|
||
display: block;
|
||
width: 90px;
|
||
height: 6px;
|
||
border-radius: 99px;
|
||
background: #eef2f7;
|
||
overflow: hidden;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.spread i {
|
||
display: block;
|
||
height: 100%;
|
||
border-radius: 99px;
|
||
}
|
||
|
||
.delta-up {
|
||
color: #198754;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.delta-down {
|
||
color: #dc3545;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.spk {
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.margin-card {
|
||
background: repeating-linear-gradient(135deg, #faf5ff, #faf5ff 12px, #f5f3ff 12px, #f5f3ff 24px);
|
||
border: 1.5px dashed #c4b5fd;
|
||
border-radius: 16px;
|
||
padding: 18px;
|
||
color: #4c1d95;
|
||
}
|
||
|
||
.dataTables_wrapper .dt-buttons .btn {
|
||
border-radius: 10px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 50px 20px;
|
||
color: var(--c-muted);
|
||
}
|
||
|
||
/* dettaglio */
|
||
.dm-head {
|
||
display: flex;
|
||
gap: 16px;
|
||
align-items: flex-start;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.dm-photo {
|
||
width: 110px;
|
||
height: 110px;
|
||
border-radius: 16px;
|
||
object-fit: cover;
|
||
border: 1px solid var(--c-line);
|
||
background: #f8fafc;
|
||
cursor: zoom-in;
|
||
}
|
||
|
||
.dm-title {
|
||
font-size: 1.35rem;
|
||
font-weight: 800;
|
||
color: var(--c-ink);
|
||
}
|
||
|
||
.dm-facts {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.fact {
|
||
background: #fff;
|
||
border: 1px solid var(--c-line);
|
||
border-radius: 10px;
|
||
padding: 5px 10px;
|
||
font-size: .85rem;
|
||
color: var(--c-muted);
|
||
}
|
||
|
||
.fact b {
|
||
color: var(--c-ink);
|
||
}
|
||
|
||
.lchip {
|
||
display: inline-block;
|
||
border-radius: 999px;
|
||
padding: 3px 10px;
|
||
font-size: .78rem;
|
||
font-weight: 800;
|
||
color: #1f2d3d;
|
||
}
|
||
|
||
.dev-pill {
|
||
display: inline-block;
|
||
font-weight: 800;
|
||
font-size: .78rem;
|
||
border-radius: 999px;
|
||
padding: 2px 9px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.dev-pill.hi {
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
}
|
||
|
||
.dev-pill.lo {
|
||
background: #fee2e2;
|
||
color: #991b1b;
|
||
}
|
||
|
||
.dev-pill.eq {
|
||
background: #f1f5f9;
|
||
color: #475569;
|
||
}
|
||
|
||
.dm-tabs {
|
||
display: flex;
|
||
gap: 4px;
|
||
flex-wrap: wrap;
|
||
background: #f1f5f9;
|
||
border-radius: 12px;
|
||
padding: 4px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.dm-tabs button {
|
||
border: 0;
|
||
background: transparent;
|
||
padding: 9px 16px;
|
||
border-radius: 9px;
|
||
font-weight: 700;
|
||
color: var(--c-muted);
|
||
}
|
||
|
||
.dm-tabs button.active {
|
||
background: #fff;
|
||
color: var(--c-ink);
|
||
box-shadow: 0 2px 6px rgba(15, 23, 42, .1);
|
||
}
|
||
|
||
.ord-link {
|
||
font-weight: 700;
|
||
text-decoration: none;
|
||
}
|
||
</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">
|
||
<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-shapes"></i></div>
|
||
<div>
|
||
<h5 class="mb-1" style="font-weight:800;">Matrici: prezzi e mescole</h5>
|
||
<span class="sync-pill" id="syncPill"><span class="dot"></span><span id="syncText">Dati Mago4</span></span>
|
||
</div>
|
||
</div>
|
||
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">↩️ Torna alla Dashboard</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card-body pt-0">
|
||
<div class="stats-tabs">
|
||
<a href="sales_summary.php"><i class="fa-solid fa-gauge-high me-1"></i> Sommario</a>
|
||
<a href="sales_orders_stats.php"><i class="fa-solid fa-list-check me-1"></i> Andamento ordini</a>
|
||
<a href="sales_orders_years.php"><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>
|
||
<a href="sales_matrici.php" class="active"><i class="fa-solid fa-shapes me-1"></i> Matrici</a>
|
||
<?php if ($canQuote) : ?>
|
||
<a href="sales_preventivo.php"><i class="fa-solid fa-calculator me-1"></i> Preventivo</a>
|
||
<?php endif; ?>
|
||
<?php if ($canDeliveries) : ?>
|
||
<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.matrici"><i class="fa-regular fa-circle-question me-1"></i> Come si legge</button>
|
||
</div>
|
||
|
||
<!-- FILTRI -->
|
||
<div class="filters-box mb-3">
|
||
<div class="row g-3">
|
||
<div class="col-xl-7">
|
||
<label class="flabel">Periodo</label>
|
||
<div class="mxrow" id="fPeriod">
|
||
<button type="button" class="mxchip" data-p="12m">Ultimi 12 mesi</button>
|
||
<button type="button" class="mxchip" data-p="ytd">Anno in corso</button>
|
||
<button type="button" class="mxchip" data-p="ly">Anno scorso</button>
|
||
<button type="button" class="mxchip" data-p="24m">24 mesi</button>
|
||
<button type="button" class="mxchip" data-p="36m">36 mesi</button>
|
||
<button type="button" class="mxchip" data-p="all">Tutto</button>
|
||
<input type="date" class="form-control mx-date" id="fFrom" title="Dal">
|
||
<input type="date" class="form-control mx-date" id="fTo" title="Al">
|
||
</div>
|
||
</div>
|
||
<div class="col-xl-5">
|
||
<label class="flabel" for="fCustomers">Clienti</label>
|
||
<div class="d-flex gap-2">
|
||
<div class="flex-grow-1"><select id="fCustomers" multiple style="width:100%;"></select></div>
|
||
<button type="button" class="btn btn-light" id="btnReset" title="Azzera i filtri" style="border-radius:10px;min-width:46px;">🧽</button>
|
||
</div>
|
||
</div>
|
||
<div class="col-xl-8">
|
||
<label class="flabel" data-help="mx.types">Tipo</label>
|
||
<div class="mxrow" id="fType">
|
||
<button type="button" class="mxchip" data-t="all">Tutto</button>
|
||
<button type="button" class="mxchip" data-t="M">Matrici</button>
|
||
<button type="button" class="mxchip" data-t="T">Tubi</button>
|
||
<button type="button" class="mxchip" data-t="R">Rettangoli</button>
|
||
<button type="button" class="mxchip" data-t="D">Diametri</button>
|
||
<button type="button" class="mxchip" data-t="other">Altri</button>
|
||
<button type="button" class="mxchip" data-t="todo" title="Matrici non in anagrafica, altra revisione e valori non riconosciuti">Da sistemare</button>
|
||
</div>
|
||
</div>
|
||
<div class="col-xl-4">
|
||
<label class="flabel">Unità di vendita</label>
|
||
<div class="mxrow" id="fUom">
|
||
<button type="button" class="mxchip" data-u="all">Tutte</button>
|
||
<button type="button" class="mxchip" data-u="m">Metri</button>
|
||
<button type="button" class="mxchip" data-u="kg">Chili</button>
|
||
<button type="button" class="mxchip" data-u="pz">Pezzi</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="onlyBanner" class="only-banner mb-3" style="display:none;"></div>
|
||
|
||
<div id="loading" class="empty-state">
|
||
<div class="spinner-border text-secondary mb-2"></div>
|
||
<div>Carico le righe d'ordine…</div>
|
||
</div>
|
||
|
||
<div id="content" style="display:none;">
|
||
<div class="mxk-grid mb-3" id="kpis"></div>
|
||
|
||
<div class="d-flex justify-content-end mb-2"><button type="button" data-layout-btn="sales_matrici"></button></div>
|
||
<div class="row g-3 mb-3" <?= UiPrefs::attr($pdo, $iduserlogin ?? 0, 'sales_matrici') ?>>
|
||
|
||
<div class="col-xl-7" data-widget="map" data-title="Mappa dei prezzi">
|
||
<div class="chart-card">
|
||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||
<div>
|
||
<div class="chart-title" data-help="mx.map">Mappa dei prezzi</div>
|
||
<div class="chart-hint">Ogni bolla è una matrice: più a destra = più venduta, più in alto = prezzo più alto, più grande = più fatturato. Clic per il dettaglio.</div>
|
||
</div>
|
||
<div class="seg" id="mapUom">
|
||
<button type="button" data-u="m" class="active">€/metro</button>
|
||
<button type="button" data-u="kg">€/kg</button>
|
||
</div>
|
||
</div>
|
||
<div id="chMap" style="min-height:380px;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-xl-5" data-widget="moves" data-title="Prezzi in movimento">
|
||
<div class="chart-card">
|
||
<div class="chart-title" data-help="mx.moves">Prezzi in movimento</div>
|
||
<div class="chart-hint mb-2">Prezzo medio degli ultimi 12 mesi rispetto ai 12 precedenti</div>
|
||
<div class="row g-2">
|
||
<div class="col-md-6 col-xl-12 col-xxl-6 mv-col">
|
||
<h6 class="text-success"><i class="fa-solid fa-arrow-trend-up me-1"></i> In aumento</h6>
|
||
<div id="mvUp"></div>
|
||
</div>
|
||
<div class="col-md-6 col-xl-12 col-xxl-6 mv-col">
|
||
<h6 class="text-danger"><i class="fa-solid fa-arrow-trend-down me-1"></i> In calo</h6>
|
||
<div id="mvDown"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-12" data-widget="table" data-title="Classifica matrici">
|
||
<div class="chart-card">
|
||
<div class="chart-title" data-help="mx.table">Classifica matrici</div>
|
||
<div class="chart-hint mb-2">Prezzo medio = imponibile delle righe ÷ quantità, nell'unità in cui è venduta. Clic su una riga per clienti, prezzi nel tempo e mescole.</div>
|
||
<div class="table-responsive">
|
||
<table id="tMat" class="table table-striped align-middle w-100 mx-table"></table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-xl-5" data-widget="mixtable" data-title="Fabbisogno mescole">
|
||
<div class="chart-card">
|
||
<div class="chart-title" data-help="mx.mixneed">Fabbisogno mescole</div>
|
||
<div class="chart-hint mb-2">Venduto nel periodo e <b>ancora da produrre</b> per gli ordini aperti</div>
|
||
<div class="table-responsive">
|
||
<table id="tMix" class="table table-striped align-middle w-100 mx-table"></table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-xl-7" data-widget="mixmonths" data-title="Mescole consumate per mese">
|
||
<div class="chart-card">
|
||
<div class="chart-title" data-help="mx.mix">Mescole consumate per mese (stima)</div>
|
||
<div class="chart-hint">Kg di mescola = metri venduti × g/m della scheda di lavoro. Righe a chili divise tra le mescole.</div>
|
||
<div id="chMix" style="min-height:340px;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-12" data-widget="margin" data-title="Margine sul costo mescola">
|
||
<div class="chart-card">
|
||
<div class="chart-title" data-help="mx.margin">Margine sul costo mescola</div>
|
||
<div class="chart-hint mb-2">Prezzo di vendita meno il costo della sola mescola (g/m della scheda di lavoro × €/kg pagato). Clic su una matrice per il dettaglio.</div>
|
||
<div id="mgEmpty" class="margin-card" style="display:none;"></div>
|
||
<div class="row g-3" id="mgLists">
|
||
<div class="col-lg-6">
|
||
<h6 class="fw-bold text-danger mb-1"><i class="fa-solid fa-triangle-exclamation me-1"></i> Margine più basso</h6>
|
||
<div id="mgLow"></div>
|
||
</div>
|
||
<div class="col-lg-6">
|
||
<h6 class="fw-bold text-success mb-1"><i class="fa-solid fa-trophy me-1"></i> Più margine in euro</h6>
|
||
<div id="mgTop"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<?php include('include/footer.php'); ?>
|
||
</div>
|
||
|
||
<!-- DETTAGLIO MATRICE -->
|
||
<div class="modal fade" id="mxModal" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog modal-xl modal-fullscreen-lg-down modal-dialog-scrollable">
|
||
<div class="modal-content" style="border-radius:18px;">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title fw-bold"><i class="fa-solid fa-shapes me-2 text-primary"></i><span id="dmTitle">Matrice</span></h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
|
||
</div>
|
||
<div class="modal-body" id="dmBody" style="background:#f8fafc;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<?php include('jsinclude.php'); ?>
|
||
<script>
|
||
$(function() {
|
||
$('.sidebar-wrapper').hover(function() {
|
||
$('.wrapper.toggled').addClass('sidebar-hovered');
|
||
}, function() {
|
||
$('.wrapper').removeClass('sidebar-hovered');
|
||
});
|
||
});
|
||
</script>
|
||
<script>
|
||
(function() {
|
||
const STORE_KEY = 'zibo_sales_matrici_v1';
|
||
const CAN_QUOTE = <?= $canQuote ? 'true' : 'false' ?>;
|
||
const MONTHS = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'];
|
||
const WARN = {
|
||
revisione: 'altra revisione',
|
||
mappa: 'abbinata a mano',
|
||
da_creare: 'non in anagrafica'
|
||
};
|
||
const PALETTE = ['#7c3aed', '#0ea5e9', '#f59e0b', '#10b981', '#ef4444', '#ec4899', '#14b8a6', '#6366f1', '#84cc16', '#f97316', '#94a3b8'];
|
||
const UOM_LABEL = {
|
||
m: 'metro',
|
||
kg: 'kg',
|
||
pz: 'pezzo',
|
||
altro: ''
|
||
};
|
||
const MIX_ST = {
|
||
pz: ['tb-pz', 'A pezzi: in attesa'],
|
||
gm: ['tb-gm', 'g/m mancante'],
|
||
gm_part: ['tb-gm', 'g/m incompleto'],
|
||
none: ['tb-gm', 'Senza mescole'],
|
||
altro: ['tb-altro', 'UM non gestita']
|
||
};
|
||
|
||
|
||
let state = {
|
||
p: '12m',
|
||
from: '',
|
||
to: '',
|
||
c: [],
|
||
t: 'all',
|
||
u: 'all',
|
||
mapU: 'm'
|
||
};
|
||
let customers = [];
|
||
let data = null;
|
||
let charts = {};
|
||
let tables = {};
|
||
let reqSeq = 0;
|
||
let detail = null;
|
||
|
||
/* ---------- UTILS ---------- */
|
||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
} [c]));
|
||
const fmtNum = (n, d) => (+n || 0).toLocaleString('it-IT', {
|
||
minimumFractionDigits: d || 0,
|
||
maximumFractionDigits: d || 0
|
||
});
|
||
const fmtEur = (n, d) => (+n || 0).toLocaleString('it-IT', {
|
||
style: 'currency',
|
||
currency: 'EUR',
|
||
minimumFractionDigits: d || 0,
|
||
maximumFractionDigits: d || 0
|
||
});
|
||
const fmtPrice = v => v === null || v === undefined ? '—' : fmtEur(v, Math.abs(v) >= 10 ? 2 : 3);
|
||
|
||
// numeri grandi leggibili: 12,5 t · 125 mila m · 1,2 mln m · 85 k€
|
||
function fmtShort(n, unit) {
|
||
n = +n || 0;
|
||
const a = Math.abs(n);
|
||
const nf = (v, d) => v.toLocaleString('it-IT', {
|
||
maximumFractionDigits: d
|
||
});
|
||
if (unit === '€') {
|
||
if (a >= 1e6) return nf(n / 1e6, 2) + ' M€';
|
||
if (a >= 1e4) return nf(n / 1e3, 1) + ' k€';
|
||
return fmtNum(n) + ' €';
|
||
}
|
||
if (unit === 'kg' && a >= 1e4) return nf(n / 1e3, a >= 1e5 ? 0 : 1) + ' t';
|
||
const u = unit ? ' ' + unit : '';
|
||
if (a >= 1e6) return nf(n / 1e6, 2) + ' mln' + u;
|
||
if (a >= 1e4) return nf(n / 1e3, 1) + ' mila' + u;
|
||
return fmtNum(n) + u;
|
||
}
|
||
const fmtEurShort = n => Math.abs(+n || 0) >= 1e4 ? fmtShort(n, '€') : fmtEur(n);
|
||
|
||
function fmtDate(v) {
|
||
if (!v) return '';
|
||
const p = String(v).substring(0, 10).split('-');
|
||
return `${p[2]}/${p[1]}/${p[0]}`;
|
||
}
|
||
const iso = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||
const qtyFmt = v => fmtNum(+v, +v % 1 ? 2 : 0);
|
||
|
||
function pctSpan(p) {
|
||
if (p === null || p === undefined || !isFinite(p)) return '<span class="text-muted">—</span>';
|
||
const x = Math.round(p * 10) / 10;
|
||
return `<span class="${x >= 0 ? 'delta-up' : 'delta-down'}">${x >= 0 ? '▲' : '▼'} ${Math.abs(x).toLocaleString('it-IT')}%</span>`;
|
||
}
|
||
|
||
// badge del tipo (M matrice, T tubo, R rettangolo, D diametro…) + avviso sul riconoscimento
|
||
function typeBadge(r) {
|
||
const cats = (data && data.cats) || {};
|
||
const w = WARN[r.type] ? ` <span class="tb tb-warn" title="Riconoscimento">${WARN[r.type]}</span>` : '';
|
||
return `<span class="tb tb-${esc(r.cat)}">${esc(cats[r.cat] || r.cat)}</span>${w}`;
|
||
}
|
||
|
||
function thumb(src, sz) {
|
||
const s = sz ? `style="width:${sz}px;height:${sz}px;"` : '';
|
||
return src ? `<img class="mx-thumb" ${s} src="${esc(src)}" loading="lazy" onerror="this.outerHTML='<div class="mx-thumb ph"><i class="fa-solid fa-shapes"></i></div>'">` :
|
||
`<div class="mx-thumb ph" ${s}><i class="fa-solid fa-shapes"></i></div>`;
|
||
}
|
||
|
||
function spark(vals) {
|
||
const pts = vals.map((v, i) => v === null ? null : [i, v]).filter(Boolean);
|
||
if (pts.length < 2) return '<svg class="spk" width="84" height="26"></svg>';
|
||
const ys = pts.map(p => p[1]);
|
||
const mn = Math.min(...ys),
|
||
mx = Math.max(...ys),
|
||
rg = mx - mn || 1;
|
||
const xy = p => [(p[0] / 11 * 80 + 2).toFixed(1), (22 - (p[1] - mn) / rg * 18 + 1).toFixed(1)];
|
||
const line = pts.map(p => xy(p).join(',')).join(' ');
|
||
const up = ys[ys.length - 1] >= ys[0];
|
||
const last = xy(pts[pts.length - 1]);
|
||
return `<svg class="spk" width="84" height="26"><polyline points="${line}" fill="none" stroke="${up ? '#16a34a' : '#dc2626'}" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/><circle cx="${last[0]}" cy="${last[1]}" r="2.6" fill="${up ? '#16a34a' : '#dc2626'}"/></svg>`;
|
||
}
|
||
|
||
function mixCell(r) {
|
||
if (MIX_ST[r.mix_status] && !(r.mix_status === 'gm_part' && r.kg > 0)) {
|
||
const m = MIX_ST[r.mix_status];
|
||
return `<span class="tb ${m[0]}">${m[1]}</span>`;
|
||
}
|
||
if (!r.id) return '<span class="text-muted">—</span>';
|
||
return `<b>${fmtShort(r.kg, 'kg')}</b>${r.mix_status === 'gm_part' ? ' <span class="tb tb-gm" title="Su qualche mescola manca il g/m">parziale</span>' : ''}<div class="small text-muted">${fmtNum(r.mix_n)} mescol${r.mix_n === 1 ? 'a' : 'e'}${r.gm_total ? ' · ' + fmtNum(r.gm_total, 1) + ' g/m' : ''}</div>`;
|
||
}
|
||
|
||
|
||
const DT_LANG = {
|
||
url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json'
|
||
};
|
||
const DT_DOM = "<'row mb-2'<'col-md-6'B><'col-md-6'f>>rt<'row mt-2'<'col-md-5'i><'col-md-7'p>>";
|
||
const xls = title => ({
|
||
dom: {
|
||
button: {
|
||
className: 'btn'
|
||
}
|
||
},
|
||
buttons: [{
|
||
extend: 'excelHtml5',
|
||
text: '<i class="fa-solid fa-file-excel me-1"></i> Excel',
|
||
className: 'btn btn-outline-success btn-sm',
|
||
title: title,
|
||
exportOptions: {
|
||
orthogonal: 'export'
|
||
}
|
||
}]
|
||
});
|
||
|
||
function makeTable(key, el, opts) {
|
||
if (tables[key]) {
|
||
tables[key].destroy();
|
||
$(el).empty();
|
||
}
|
||
tables[key] = $(el).DataTable(Object.assign({
|
||
pageLength: 25,
|
||
lengthMenu: [10, 25, 50, 100, 500],
|
||
dom: DT_DOM,
|
||
language: DT_LANG,
|
||
autoWidth: false
|
||
}, opts));
|
||
return tables[key];
|
||
}
|
||
|
||
function upsert(key, el, opts) {
|
||
if (charts[key]) {
|
||
charts[key].destroy();
|
||
delete charts[key];
|
||
}
|
||
charts[key] = new ApexCharts(document.getElementById(el), opts);
|
||
charts[key].render();
|
||
}
|
||
|
||
/* ---------- STATO ---------- */
|
||
function save() {
|
||
try {
|
||
localStorage.setItem(STORE_KEY, JSON.stringify(state));
|
||
} catch (e) {}
|
||
}
|
||
|
||
function restore() {
|
||
try {
|
||
const s = JSON.parse(localStorage.getItem(STORE_KEY) || '{}');
|
||
if (s && typeof s === 'object') state = Object.assign(state, s);
|
||
} catch (e) {}
|
||
const q = new URLSearchParams(location.search);
|
||
if (q.get('c')) state.c = q.get('c').split(',').filter(Boolean);
|
||
if (!['all', 'M', 'T', 'R', 'D', 'other', 'todo'].includes(state.t)) state.t = 'all';
|
||
}
|
||
|
||
function periodRange(p) {
|
||
const t = new Date();
|
||
const y = t.getFullYear();
|
||
const back = days => iso(new Date(t.getTime() - days * 864e5));
|
||
switch (p) {
|
||
case 'ytd':
|
||
return [y + '-01-01', iso(t)];
|
||
case 'ly':
|
||
return [(y - 1) + '-01-01', (y - 1) + '-12-31'];
|
||
case '24m':
|
||
return [back(730), iso(t)];
|
||
case '36m':
|
||
return [back(1095), iso(t)];
|
||
case 'all':
|
||
return ['2020-01-01', iso(t)];
|
||
case 'custom':
|
||
return [state.from, state.to];
|
||
default:
|
||
return [back(365), iso(t)];
|
||
}
|
||
}
|
||
|
||
function syncUi() {
|
||
if (state.p !== 'custom')[state.from, state.to] = periodRange(state.p);
|
||
$('#fPeriod .mxchip').each(function() {
|
||
$(this).toggleClass('active', $(this).data('p') === state.p);
|
||
});
|
||
$('#fFrom').val(state.from);
|
||
$('#fTo').val(state.to);
|
||
$('#fType .mxchip').each(function() {
|
||
$(this).toggleClass('active', $(this).data('t') === state.t);
|
||
});
|
||
$('#fUom .mxchip').each(function() {
|
||
$(this).toggleClass('active', $(this).data('u') === state.u);
|
||
});
|
||
$('#mapUom button').each(function() {
|
||
$(this).toggleClass('active', $(this).data('u') === state.mapU);
|
||
});
|
||
}
|
||
|
||
function rowsFiltered() {
|
||
return (data.rows || []).filter(r => {
|
||
if (['M', 'T', 'R', 'D'].includes(state.t) && r.cat !== state.t) return false;
|
||
if (state.t === 'other' && ['M', 'T', 'R', 'D'].includes(r.cat)) return false;
|
||
if (state.t === 'todo' && !['revisione', 'da_creare', 'altro'].includes(r.type)) return false;
|
||
if (state.u !== 'all' && r.kind !== state.u) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/* ---------- CARICAMENTO ---------- */
|
||
function loadFilters() {
|
||
return fetch('?action=filters').then(r => r.json()).then(d => {
|
||
customers = d.customers || [];
|
||
const $s = $('#fCustomers').empty();
|
||
customers.forEach(c => $s.append(new Option(`${c.name} (${c.code})`, c.code, false, state.c.includes(c.code))));
|
||
$s.trigger('change.select2');
|
||
const so = (d.sync || {}).sale_ord || {};
|
||
$('#syncPill').toggleClass('ok', so.last_status === 'ok').toggleClass('err', so.last_status === 'errore');
|
||
$('#syncText').text(so.last_run_at ? `Aggiornato il ${so.last_run_at}` : 'Dati Mago4');
|
||
});
|
||
}
|
||
|
||
function load() {
|
||
syncUi();
|
||
save();
|
||
const seq = ++reqSeq;
|
||
$('#content').css('opacity', .55);
|
||
const u = new URL(location.href);
|
||
if (state.c.length) u.searchParams.set('c', state.c.join(','));
|
||
else u.searchParams.delete('c');
|
||
history.replaceState(null, '', u.toString());
|
||
|
||
const qs = new URLSearchParams({
|
||
action: 'list',
|
||
from: state.from,
|
||
to: state.to,
|
||
c: state.c.join(',')
|
||
});
|
||
return fetch('?' + qs).then(r => r.json()).then(d => {
|
||
if (seq !== reqSeq) return;
|
||
$('#loading').hide();
|
||
$('#content').show().css('opacity', 1);
|
||
if (!d.success) {
|
||
$('#kpis').html(`<div class="alert alert-danger">${esc(d.message || 'Errore nel caricamento')}</div>`);
|
||
return;
|
||
}
|
||
data = d;
|
||
renderBanner();
|
||
renderKpis();
|
||
renderAll();
|
||
renderMix();
|
||
}).catch(e => {
|
||
console.error(e);
|
||
$('#loading').html('<div class="text-danger">Errore nel caricamento: ' + esc(e && e.message ? e.message : e) + '</div>');
|
||
});
|
||
}
|
||
|
||
function renderAll() {
|
||
renderMap();
|
||
renderMoves();
|
||
renderTable();
|
||
renderMargin();
|
||
}
|
||
|
||
function renderBanner() {
|
||
if (!state.c.length) return $('#onlyBanner').hide();
|
||
const names = state.c.map(c => (customers.find(x => x.code === c) || {}).name || c);
|
||
const one = state.c.length === 1;
|
||
$('#onlyBanner').show().html(`<i class="fa-solid fa-filter me-2"></i>Stai guardando solo cosa ordina${one ? '' : 'no'}: <b>${esc(names.slice(0, 4).join(', '))}${names.length > 4 ? ' e altri ' + (names.length - 4) : ''}</b>
|
||
${one ? `<a class="ms-2" href="sales_customer.php?c=${encodeURIComponent(state.c[0])}"><i class="fa-solid fa-address-card me-1"></i>Scheda cliente</a>` : ''}
|
||
<a href="#" class="ms-3" id="clearCust">Togli il filtro</a>`);
|
||
}
|
||
|
||
/* ---------- KPI ---------- */
|
||
function renderKpis() {
|
||
const k = data.kpi;
|
||
const pm = k.pq_m > 0 ? k.val_m / k.pq_m : null;
|
||
const pk = k.pq_kg > 0 ? k.val_kg / k.pq_kg : null;
|
||
const tot = k.linked_value + k.profile_value + k.unlinked_value || 1;
|
||
const pl = k.linked_value / tot * 100,
|
||
pp = k.profile_value / tot * 100,
|
||
pu = k.unlinked_value / tot * 100;
|
||
const card = (color, icon, lbl, big, sub, attrs, help) => `<div class="mxk ${attrs ? 'click' : ''}" style="--k:${color};" ${attrs || ''}>
|
||
<div class="ico"><i class="fa-solid ${icon}"></i></div><div class="lbl"${help ? ` data-help="${help}"` : ''}>${lbl}</div><div class="big">${big}</div><div class="sub">${sub}</div></div>`;
|
||
$('#kpis').html([
|
||
card('#7c3aed', 'fa-sack-dollar', 'Venduto con matrice', fmtEurShort(k.value),
|
||
`<b>${fmtNum(k.lines)}</b> righe · <b>${fmtNum(k.matrici)}</b> matrici<br><b>${fmtNum(k.clients)}</b> clienti`, '', 'mx.k.value'),
|
||
card('#0ea5e9', 'fa-ruler-horizontal', 'Venduto a metri', fmtShort(k.qty_m, 'm'),
|
||
`prezzo medio <b>${fmtPrice(pm)}/m</b>`, 'data-goto-u="m"', 'mx.k.qty'),
|
||
card('#f59e0b', 'fa-weight-hanging', 'Venduto a chili', fmtShort(k.qty_kg, 'kg'),
|
||
`prezzo medio <b>${fmtPrice(pk)}/kg</b>`, 'data-goto-u="kg"', 'mx.k.qty'),
|
||
card('#10b981', 'fa-flask', 'Mescola consumata (stima)', fmtShort(k.kg_mix, 'kg'),
|
||
`ancora da produrre: <b>${fmtShort(k.kg_open, 'kg')}</b><br>per ${fmtEurShort(k.open_value)} di ordini aperti`, '', 'mx.k.mix'),
|
||
card('#ec4899', 'fa-cubes', 'Venduto a pezzi', fmtNum(k.pz_lines) + ' righe',
|
||
`${fmtEurShort(k.pz_value)} · <span class="tb tb-pz">mescole in attesa</span><br>serve la conversione pezzi → metri`, 'data-goto-u="pz"', 'mx.k.pz'),
|
||
marginCard(card, k),
|
||
card('#64748b', 'fa-link', 'Copertura anagrafica', fmtNum(pl, 0) + '%',
|
||
`del venduto è collegato a una matrice<div class="mxbar"><i style="width:${pl}%;background:#22c55e"></i><i style="width:${pp}%;background:#a78bfa"></i><i style="width:${pu}%;background:#f97316"></i></div>
|
||
<span style="color:#7c3aed">tubi/rett./diam. ${fmtNum(pp, 0)}%</span> · <span style="color:#ea580c">da sistemare ${fmtNum(pu, 0)}%</span>${k.gm_missing_lines ? `<br><span class="tb tb-gm">${fmtNum(k.gm_missing_lines)} righe senza g/m</span>` : ''}`, 'data-goto-t="todo"', 'mx.k.cover')
|
||
].join(''));
|
||
}
|
||
|
||
function marginCard(card, k) {
|
||
if (!data.has_prices) return card('#16a34a', 'fa-scale-balanced', 'Margine sul grezzo', '—', 'Prezzi mescole non ancora disponibili:<br>serve il sync acquisti', '', 'mx.k.margin');
|
||
const pct = k.cost_cover_value > 0 ? (k.cost_cover_value - k.cost_value) / k.cost_cover_value * 100 : null;
|
||
const cov = k.value > 0 ? k.cost_cover_value / k.value * 100 : 0;
|
||
return card(pct !== null && pct < 50 ? '#dc2626' : '#16a34a', 'fa-scale-balanced', 'Margine sul grezzo', pct === null ? '—' : fmtNum(pct, 0) + '%',
|
||
`costo mescola <b>${fmtEurShort(k.cost_value)}</b><br>calcolato sul <b>${fmtNum(cov, 0)}%</b> del venduto${k.check_rows ? `<br><span class="tb tb-check">${fmtNum(k.check_rows)} con prezzo da verificare</span>` : ''}`, '', 'mx.k.margin');
|
||
}
|
||
|
||
function marginSpan(pct) {
|
||
if (pct === null || pct === undefined) return '<span class="text-muted">—</span>';
|
||
const c = pct >= 60 ? 'mg-hi' : pct >= 40 ? 'mg-mid' : 'mg-lo';
|
||
return `<span class="mg-pill ${c}">${fmtNum(pct, 0)}%</span>`;
|
||
}
|
||
|
||
const COST_ST = {
|
||
pz: 'a pezzi: in attesa',
|
||
gm: 'g/m mancante',
|
||
none: 'senza mescole',
|
||
price: 'prezzo mescola mancante',
|
||
altro: 'UM non gestita'
|
||
};
|
||
|
||
function costCell(r) {
|
||
if (r.cost === null || r.cost === undefined) {
|
||
if (!r.id) return '<span class="text-muted">—</span>';
|
||
return `<span class="tb ${r.cost_status === 'pz' ? 'tb-pz' : 'tb-gm'}">${COST_ST[r.cost_status] || '—'}</span>`;
|
||
}
|
||
const ul = UOM_LABEL[r.kind] || r.uom;
|
||
let b = '';
|
||
if (r.cost_status === 'partial') b += ` <span class="tb tb-gm" title="${esc((r.cost_missing || []).length ? 'Senza prezzo: ' + r.cost_missing.join(', ') : 'Manca il g/m di qualche mescola')}">parziale</span>`;
|
||
if ((r.cost_check || []).length) b += ` <span class="tb tb-check" title="Prezzo ${esc(r.cost_check.join(', '))} da verificare (conto lavoro?)">da verificare</span>`;
|
||
return `<b>${fmtPrice(r.cost)}</b><span class="small text-muted">/${ul}</span>${b}`;
|
||
}
|
||
|
||
function renderMargin() {
|
||
const rows = rowsFiltered().filter(r => r.margin_pct !== null && r.n > 0 && r.cost_status === 'ok');
|
||
if (!data.has_prices || !rows.length) {
|
||
$('#mgLists').hide();
|
||
$('#mgEmpty').show().html(!data.has_prices ?
|
||
'<b>Prezzi mescole non ancora disponibili.</b> Si attivano con il sync acquisti (mago_sync_mix_cli.php).' :
|
||
'<b>Nessuna matrice con costo completo</b> nei filtri scelti: servono scheda di lavoro con g/m e prezzo di tutte le mescole.');
|
||
return;
|
||
}
|
||
$('#mgEmpty').hide();
|
||
$('#mgLists').show();
|
||
const item = (r, right) => `<div class="mg-item" data-k="${esc(r.key)}">${thumb(r.photo, 36)}
|
||
<div style="min-width:0;flex:1;"><div class="nm text-truncate">${esc(r.name)} <span class="uom">${esc(r.uom)}</span></div>
|
||
<div class="pr">vendita ${fmtPrice(r.price)} · costo ${fmtPrice(r.cost)} /${UOM_LABEL[r.kind]}${(r.cost_check || []).length ? ' · <span class="tb tb-check">da verificare</span>' : ''}</div></div>
|
||
<div class="text-end">${right}</div></div>`;
|
||
const low = rows.slice().sort((a, b) => a.margin_pct - b.margin_pct).slice(0, 8);
|
||
const top = rows.map(r => Object.assign({}, r, {
|
||
mval: r.margin * (r.value / r.price)
|
||
})).sort((a, b) => b.mval - a.mval).slice(0, 8);
|
||
$('#mgLow').html(low.map(r => item(r, marginSpan(r.margin_pct))).join(''));
|
||
$('#mgTop').html(top.map(r => item(r, `<b>${fmtEurShort(r.mval)}</b><div>${marginSpan(r.margin_pct)}</div>`)).join(''));
|
||
}
|
||
|
||
/* ---------- MAPPA ---------- */
|
||
let mapRows = [];
|
||
|
||
function renderMap() {
|
||
const u = state.mapU;
|
||
mapRows = rowsFiltered().filter(r => r.kind === u && r.price > 0 && r.qty > 0).sort((a, b) => b.value - a.value).slice(0, 150);
|
||
const maxV = Math.max(1, ...mapRows.map(r => r.value));
|
||
const pts = mapRows.map(r => [Math.log10(r.qty), r.price, Math.max(4, Math.sqrt(r.value / maxV) * 40)]);
|
||
upsert('map', 'chMap', {
|
||
chart: {
|
||
type: 'bubble',
|
||
height: 380,
|
||
fontFamily: 'inherit',
|
||
toolbar: {
|
||
show: false
|
||
},
|
||
animations: {
|
||
speed: 350
|
||
},
|
||
events: {
|
||
dataPointSelection: (e, c, o) => {
|
||
const r = mapRows[o.dataPointIndex];
|
||
if (r) openDetail(r.key);
|
||
}
|
||
}
|
||
},
|
||
series: [{
|
||
name: 'Matrici',
|
||
data: pts
|
||
}],
|
||
colors: ['#7c3aed'],
|
||
fill: {
|
||
opacity: .55
|
||
},
|
||
stroke: {
|
||
width: 1,
|
||
colors: ['#5b21b6']
|
||
},
|
||
dataLabels: {
|
||
enabled: false
|
||
},
|
||
plotOptions: {
|
||
bubble: {
|
||
minBubbleRadius: 4,
|
||
maxBubbleRadius: 40
|
||
}
|
||
},
|
||
xaxis: {
|
||
type: 'numeric',
|
||
tickAmount: 6,
|
||
title: {
|
||
text: `Quantità venduta (${u === 'm' ? 'metri' : 'kg'}, scala log)`,
|
||
style: {
|
||
fontWeight: 600,
|
||
color: '#64748b'
|
||
}
|
||
},
|
||
labels: {
|
||
formatter: v => fmtShort(Math.pow(10, v))
|
||
}
|
||
},
|
||
yaxis: {
|
||
title: {
|
||
text: u === 'm' ? '€/metro' : '€/kg'
|
||
},
|
||
labels: {
|
||
formatter: v => fmtPrice(v)
|
||
}
|
||
},
|
||
grid: {
|
||
borderColor: '#eef2f7'
|
||
},
|
||
noData: {
|
||
text: 'Nessuna matrice venduta in questa unità'
|
||
},
|
||
tooltip: {
|
||
custom: ({
|
||
dataPointIndex
|
||
}) => {
|
||
const r = mapRows[dataPointIndex];
|
||
if (!r) return '';
|
||
return `<div style="padding:10px 12px;max-width:280px;">
|
||
<div style="font-weight:800;">${esc(r.name)}</div>
|
||
<div style="margin:2px 0 6px;">${typeBadge(r)}</div>
|
||
<div>Prezzo medio: <b>${fmtPrice(r.price)}/${UOM_LABEL[r.kind]}</b></div>
|
||
<div>Forbice: ${fmtPrice(r.pmin)} – ${fmtPrice(r.pmax)}</div>
|
||
<div>Venduto: <b>${qtyFmt(r.qty)} ${esc(r.uom)}</b> · ${fmtEur(r.value)}</div>
|
||
<div>${fmtNum(r.clients)} clienti · ${fmtNum(r.orders)} ordini</div></div>`;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ---------- PREZZI IN MOVIMENTO ---------- */
|
||
function renderMoves() {
|
||
const rows = rowsFiltered().filter(r => r.var !== null && r.kind !== 'altro');
|
||
const item = r => `<div class="mv-item" data-k="${esc(r.key)}">
|
||
${thumb(r.photo, 38)}
|
||
<div style="min-width:0;"><div class="nm text-truncate">${esc(r.name)} <span class="uom">${esc(r.uom)}</span></div>
|
||
<div class="pr">${fmtPrice(r.pricep)} → <b>${fmtPrice(r.price12)}</b> /${UOM_LABEL[r.kind]}</div></div>
|
||
<div class="pct">${pctSpan(r.var)}</div></div>`;
|
||
const up = rows.filter(r => r.var > 0.5).sort((a, b) => b.var-a.var).slice(0, 6);
|
||
const dn = rows.filter(r => r.var < -0.5).sort((a, b) => a.var-b.var).slice(0, 6);
|
||
const none = '<div class="text-muted small p-2">Nessuna variazione significativa</div>';
|
||
$('#mvUp').html(up.map(item).join('') || none);
|
||
$('#mvDown').html(dn.map(item).join('') || none);
|
||
}
|
||
|
||
/* ---------- CLASSIFICA ---------- */
|
||
function renderTable() {
|
||
const rows = rowsFiltered();
|
||
const maxSpread = Math.max(30, ...rows.map(r => r.spread || 0));
|
||
const sm = data.spark_months || [];
|
||
const sparkTitle = sm.length ? `${MONTHS[+sm[0].substring(5) - 1]} ${sm[0].substring(2, 4)} → ${MONTHS[+sm[11].substring(5) - 1]} ${sm[11].substring(2, 4)}` : '';
|
||
makeTable('mat', '#tMat', {
|
||
data: rows,
|
||
columns: [{
|
||
title: 'Matrice',
|
||
data: 'name',
|
||
render: (v, t, r) => {
|
||
if (t === 'export') return v;
|
||
if (t !== 'display') return v + ' ' + r.raws.join(' ');
|
||
const raw = r.raws.filter(x => x !== String(v).toUpperCase()).slice(0, 2).join(', ');
|
||
return `<div class="mx-name">${thumb(r.photo)}<div><b>${esc(v)}</b> ${typeBadge(r)}${raw ? `<div class="small text-muted">Mago: ${esc(raw)}</div>` : ''}</div></div>`;
|
||
}
|
||
}, {
|
||
title: 'UM',
|
||
data: 'uom',
|
||
className: 'text-center',
|
||
render: (v, t) => t === 'display' ? `<span class="uom">${esc(v)}</span>` : v
|
||
}, {
|
||
title: 'Clienti',
|
||
data: 'clients',
|
||
className: 'text-end'
|
||
}, {
|
||
title: 'Ordini',
|
||
data: 'orders',
|
||
className: 'text-end'
|
||
}, {
|
||
title: 'Quantità',
|
||
data: 'qty',
|
||
className: 'text-end text-nowrap',
|
||
render: (v, t, r) => t === 'display' ? `${qtyFmt(v)}${+r.open_qty ? `<div class="small text-muted" title="Ancora da consegnare">+${qtyFmt(r.open_qty)} da cons.</div>` : ''}` : +v
|
||
}, {
|
||
title: 'Valore',
|
||
data: 'value',
|
||
className: 'text-end amount',
|
||
render: (v, t) => t === 'display' ? fmtEur(v) : +v
|
||
}, {
|
||
title: 'Prezzo medio',
|
||
data: 'price',
|
||
className: 'text-end amount',
|
||
render: (v, t, r) => t === 'display' ? (v === null ? '—' : `<b>${fmtPrice(v)}</b><span class="small text-muted">/${UOM_LABEL[r.kind] || esc(r.uom)}</span>`) : (v === null ? '' : +v)
|
||
}, {
|
||
title: 'Forbice prezzi',
|
||
data: 'spread',
|
||
className: 'text-nowrap',
|
||
render: (v, t, r) => {
|
||
if (t === 'export') return r.pmin === null ? '' : `${fmtPrice(r.pmin)} - ${fmtPrice(r.pmax)}`;
|
||
if (t !== 'display') return v === null ? -1 : +v;
|
||
if (r.pmin === null) return '<span class="text-muted">—</span>';
|
||
const w = Math.min(100, (v || 0) / maxSpread * 100);
|
||
const col = v > 25 ? '#ef4444' : v > 10 ? '#f59e0b' : '#22c55e';
|
||
return `<span class="small">${fmtPrice(r.pmin)} – ${fmtPrice(r.pmax)}</span><span class="spread" title="Differenza tra prezzo minimo e massimo: ${fmtNum(v, 1)}%"><i style="width:${w}%;background:${col}"></i></span>`;
|
||
}
|
||
}, {
|
||
title: '12 mesi',
|
||
data: 'var',
|
||
className: 'text-nowrap',
|
||
render: (v, t, r) => {
|
||
if (t === 'export') return v === null ? '' : v / 100;
|
||
if (t !== 'display') return v === null ? -1e9 : +v;
|
||
return `<span title="${sparkTitle}">${spark(r.spark)}</span> ${pctSpan(v)}`;
|
||
}
|
||
}, {
|
||
title: 'Costo mescola',
|
||
data: 'cost',
|
||
className: 'text-end text-nowrap',
|
||
render: (v, t, r) => t === 'display' ? costCell(r) : (v === null ? (t === 'export' ? (COST_ST[r.cost_status] || '') : -1) : +v)
|
||
}, {
|
||
title: 'Margine',
|
||
data: 'margin_pct',
|
||
className: 'text-end',
|
||
render: (v, t) => t === 'display' ? marginSpan(v) : (v === null ? (t === 'export' ? '' : -1e9) : (t === 'export' ? v / 100 : +v))
|
||
}, {
|
||
title: 'Mescola',
|
||
data: 'kg',
|
||
className: 'text-end',
|
||
render: (v, t, r) => t === 'display' ? mixCell(r) : (t === 'export' ? (MIX_ST[r.mix_status] && !v ? MIX_ST[r.mix_status][1] : +v) : +v)
|
||
}, {
|
||
title: 'Ultimo',
|
||
data: 'last_date',
|
||
className: 'text-nowrap',
|
||
render: (v, t, r) => t === 'display' ? (v ? `${fmtDate(v)}<div class="small text-muted">${fmtPrice(r.last_price)}</div>` : '—') : (v || '')
|
||
}],
|
||
order: [
|
||
[5, 'desc']
|
||
],
|
||
buttons: xls('Matrici - prezzi ' + fmtDate(state.from) + ' - ' + fmtDate(state.to)),
|
||
createdRow: (tr, r) => $(tr).attr('data-k', r.key)
|
||
});
|
||
}
|
||
|
||
/* ---------- MESCOLE ---------- */
|
||
function renderMix() {
|
||
const ms = data.mescole || [];
|
||
const keys = [];
|
||
const s = new Date(data.from + 'T00:00:00'),
|
||
e = new Date(data.to + 'T00:00:00');
|
||
for (let d = new Date(s.getFullYear(), s.getMonth(), 1); d <= e; d.setMonth(d.getMonth() + 1)) keys.push(d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'));
|
||
const top = ms.slice(0, 8);
|
||
const series = top.map(m => ({
|
||
name: m.name,
|
||
data: keys.map(k => Math.round((m.months || {})[k] || 0))
|
||
}));
|
||
if (ms.length > 8) {
|
||
series.push({
|
||
name: 'Altre',
|
||
data: keys.map(k => Math.round(ms.slice(8).reduce((a, m) => a + ((m.months || {})[k] || 0), 0)))
|
||
});
|
||
}
|
||
upsert('mix', 'chMix', {
|
||
chart: {
|
||
type: 'bar',
|
||
stacked: true,
|
||
height: 340,
|
||
fontFamily: 'inherit',
|
||
toolbar: {
|
||
show: false
|
||
},
|
||
animations: {
|
||
speed: 350
|
||
}
|
||
},
|
||
series: series,
|
||
colors: PALETTE,
|
||
plotOptions: {
|
||
bar: {
|
||
columnWidth: '65%',
|
||
borderRadius: 2
|
||
}
|
||
},
|
||
dataLabels: {
|
||
enabled: false
|
||
},
|
||
xaxis: {
|
||
categories: keys.map(k => MONTHS[+k.substring(5) - 1] + ' ' + k.substring(2, 4)),
|
||
labels: {
|
||
rotate: -45,
|
||
hideOverlappingLabels: true
|
||
}
|
||
},
|
||
yaxis: {
|
||
labels: {
|
||
formatter: v => fmtShort(v, 'kg')
|
||
}
|
||
},
|
||
tooltip: {
|
||
shared: true,
|
||
intersect: false,
|
||
y: {
|
||
formatter: v => fmtNum(v) + ' kg'
|
||
}
|
||
},
|
||
legend: {
|
||
position: 'top',
|
||
horizontalAlign: 'left'
|
||
},
|
||
grid: {
|
||
borderColor: '#eef2f7'
|
||
},
|
||
noData: {
|
||
text: 'Nessuna mescola stimata: servono le schede di lavoro con i g/m'
|
||
}
|
||
});
|
||
|
||
makeTable('mix', '#tMix', {
|
||
data: ms,
|
||
pageLength: 10,
|
||
lengthMenu: [10, 25, 100],
|
||
columns: [{
|
||
title: 'Mescola',
|
||
data: 'name',
|
||
render: (v, t) => t === 'display' ? `<b>${esc(v)}</b>` : v
|
||
}, {
|
||
title: 'Venduto',
|
||
data: 'kg',
|
||
className: 'text-end amount',
|
||
render: (v, t) => t === 'display' ? fmtNum(v) + ' kg' : +v
|
||
}, {
|
||
title: 'Da produrre',
|
||
data: 'kg_open',
|
||
className: 'text-end amount',
|
||
render: (v, t) => t === 'display' ? (+v ? `<b style="color:#047857">${fmtNum(v)} kg</b>` : '<span class="text-muted">—</span>') : +v
|
||
}, {
|
||
title: 'Matrici',
|
||
data: 'matrici',
|
||
className: 'text-end'
|
||
}],
|
||
order: [
|
||
[1, 'desc']
|
||
],
|
||
buttons: xls('Fabbisogno mescole ' + fmtDate(state.from) + ' - ' + fmtDate(state.to))
|
||
});
|
||
$('#tMix tbody').css('cursor', 'default');
|
||
}
|
||
|
||
/* ---------- DETTAGLIO ---------- */
|
||
let dmTab = 'clients';
|
||
|
||
function openDetail(key) {
|
||
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('mxModal'));
|
||
$('#dmTitle').text('Carico…');
|
||
$('#dmBody').html('<div class="empty-state"><div class="spinner-border text-secondary"></div></div>');
|
||
modal.show();
|
||
const u = new URL(location.href);
|
||
u.searchParams.set('k', key);
|
||
history.replaceState(null, '', u.toString());
|
||
const qs = new URLSearchParams({
|
||
action: 'detail',
|
||
k: key,
|
||
from: state.from,
|
||
to: state.to,
|
||
c: state.c.join(',')
|
||
});
|
||
fetch('?' + qs).then(r => r.json()).then(d => {
|
||
if (!d.success) {
|
||
$('#dmTitle').text('Matrice');
|
||
$('#dmBody').html(`<div class="alert alert-warning">${esc(d.message || 'Errore')}</div>`);
|
||
return;
|
||
}
|
||
detail = d;
|
||
renderDetail(d);
|
||
});
|
||
}
|
||
|
||
function renderDetail(d) {
|
||
const h = d.head,
|
||
t = d.tot;
|
||
const ul = UOM_LABEL[h.kind] || h.uom;
|
||
let quoteBtn = '';
|
||
if (CAN_QUOTE && h.id && (h.kind === 'm' || h.kind === 'kg')) {
|
||
const qp = new URLSearchParams({
|
||
m: h.id,
|
||
u: h.kind
|
||
});
|
||
if (state.c.length === 1) qp.set('c', state.c[0]);
|
||
quoteBtn = ' <a class="btn btn-sm btn-success ms-2" href="sales_preventivo.php?' + qp.toString() + '"><i class="fa-solid fa-calculator me-1"></i> Preventivo</a>';
|
||
}
|
||
$('#dmTitle').html(esc(h.name) + ' <span class="uom ms-1">' + esc(h.uom) + '</span>' + quoteBtn);
|
||
const facts = [];
|
||
facts.push(`<div class="fact">${typeBadge(h)}</div>`);
|
||
if (h.raws.length) facts.push(`<div class="fact">In Mago: <b>${esc(h.raws.slice(0, 4).join(', '))}</b>${h.raws.length > 4 ? ' …' : ''}</div>`);
|
||
if (h.lines && h.lines.length) facts.push(`<div class="fact">Linee: ${h.lines.map(l => `<span class="lchip" style="background:${esc(l.color || '#e2e8f0')}33;border:1px solid ${esc(l.color || '#cbd5e1')}">${esc(l.name)}</span>`).join(' ')}</div>`);
|
||
if (h.ws) facts.push(`<div class="fact">Scheda di lavoro <b>n° ${esc(h.ws.number)}</b>${h.ws.rev ? ' rev. ' + esc(h.ws.rev) : ''}${h.ws.date ? ' del ' + fmtDate(h.ws.date) : ''}${h.ws.active ? '' : ' <span class="tb tb-gm">non attiva</span>'}</div>`);
|
||
else if (h.id) facts.push(`<div class="fact"><span class="tb tb-gm">Nessuna scheda di lavoro</span></div>`);
|
||
if (MIX_ST[h.mix_status]) facts.push(`<div class="fact"><span class="tb ${MIX_ST[h.mix_status][0]}">${MIX_ST[h.mix_status][1]}</span></div>`);
|
||
|
||
const k = (c, l, b, s) => `<div class="mxk" style="--k:${c};"><div class="lbl">${l}</div><div class="big" style="font-size:1.35rem;">${b}</div><div class="sub">${s || ''}</div></div>`;
|
||
const sp = t.pmin > 0 ? (t.pmax - t.pmin) / t.pmin * 100 : null;
|
||
|
||
$('#dmBody').html(`
|
||
<div class="dm-head mb-3">
|
||
${h.photo ? `<a href="${esc(h.photo)}" target="_blank"><img class="dm-photo" src="${esc(h.photo)}" onerror="this.style.display='none'"></a>` : ''}
|
||
<div style="flex:1;min-width:240px;">
|
||
<div class="dm-title">${esc(h.name)}</div>
|
||
<div class="dm-facts">${facts.join('')}</div>
|
||
</div>
|
||
</div>
|
||
<div class="mxk-grid mb-3" style="grid-template-columns:repeat(auto-fit,minmax(170px,1fr));">
|
||
${k('#7c3aed', 'Venduto', fmtEurShort(t.value), `${fmtNum(t.n)} righe · ${fmtNum(t.clients)} clienti`)}
|
||
${k('#0ea5e9', 'Quantità', qtyFmt(t.qty) + ' ' + esc(h.uom), t.open_qty ? `+${qtyFmt(t.open_qty)} da consegnare` : 'nessuna riga aperta')}
|
||
${k('#f59e0b', 'Prezzo medio', fmtPrice(t.price) + '<small class="text-muted fs-6">/' + ul + '</small>', '')}
|
||
${k(sp > 25 ? '#ef4444' : '#64748b', 'Forbice', t.pmin === null ? '—' : `${fmtPrice(t.pmin)} – ${fmtPrice(t.pmax)}`, sp !== null ? `differenza ${fmtNum(sp, 1)}% tra min e max` : '')}
|
||
${k('#10b981', 'Mescola (stima)', fmtShort(t.kg, 'kg'), t.open_kg ? `da produrre: <b>${fmtShort(t.open_kg, 'kg')}</b>` : '')}
|
||
${costKpis(d, k, ul)}
|
||
</div>
|
||
<div class="chart-card mb-3">
|
||
<div class="chart-title" data-help="mx.dprice">Prezzo nel tempo, cliente per cliente</div>
|
||
<div class="chart-hint">Ogni punto è una riga d'ordine; la linea scura è la media del mese, la linea rossa tratteggiata il costo della mescola. La distanza tra le due è il margine.</div>
|
||
<div id="chDetail" style="min-height:340px;"></div>
|
||
</div>
|
||
<div class="chart-card">
|
||
<div class="dm-tabs" id="dmTabs">
|
||
<button type="button" data-t="clients"><i class="fa-solid fa-users me-1"></i> Clienti <span class="badge bg-light text-dark">${fmtNum(d.clients.length)}</span></button>
|
||
<button type="button" data-t="mix"><i class="fa-solid fa-flask me-1"></i> Mescole <span class="badge bg-light text-dark">${fmtNum(d.mix.length)}</span></button>
|
||
<button type="button" data-t="lines"><i class="fa-solid fa-file-invoice me-1"></i> Righe d'ordine <span class="badge bg-light text-dark">${fmtNum(d.lines.length)}</span></button>
|
||
</div>
|
||
<div class="dm-pane" data-p="clients"><div class="table-responsive"><table id="tdClients" class="table table-striped align-middle w-100 mx-table"></table></div></div>
|
||
<div class="dm-pane" data-p="mix" style="display:none;">${mixPane(d)}</div>
|
||
<div class="dm-pane" data-p="lines" style="display:none;"><div class="table-responsive"><table id="tdLines" class="table table-striped align-middle w-100 mx-table"></table></div></div>
|
||
</div>`);
|
||
|
||
renderDetailChart(d);
|
||
renderDetailTables(d);
|
||
showDmTab(dmTab);
|
||
}
|
||
|
||
function costKpis(d, k, ul) {
|
||
const c = d.cost || {};
|
||
if (c.cost === null || c.cost === undefined) {
|
||
return k('#64748b', 'Costo mescola', '—', COST_ST[c.status] || 'non calcolabile');
|
||
}
|
||
const warn = [];
|
||
if (c.status === 'partial') warn.push(`<span class="tb tb-gm">parziale</span>`);
|
||
(c.check || []).forEach(x => warn.push(`<span class="tb tb-check">${esc(x)} da verificare</span>`));
|
||
const m = c.margin_pct;
|
||
return k('#dc2626', 'Costo mescola', fmtPrice(c.cost) + '<small class="text-muted fs-6">/' + ul + '</small>', `media ${c.price_months} mesi ${warn.join(' ')}`) +
|
||
k(m !== null && m < 40 ? '#dc2626' : '#16a34a', 'Margine sul grezzo', m === null ? '—' : fmtNum(m, 0) + '%',
|
||
m === null ? '' : `${fmtPrice(d.tot.price - c.cost)}/${ul} sul prezzo medio`);
|
||
}
|
||
|
||
function mixPane(d) {
|
||
const h = d.head;
|
||
const cost = d.cost || {};
|
||
const ul = UOM_LABEL[h.kind] || esc(h.uom);
|
||
if (h.kind === 'pz') {
|
||
return '<div class="alert alert-warning mb-0"><span class="tb tb-pz">A pezzi</span> Le righe vendute a pezzi aspettano la conversione pezzi → metri: le mescole non sono ancora stimate.</div>';
|
||
}
|
||
if (!d.mix.length) {
|
||
const msg = h.id ? 'Nessuna mescola collegata: completa la scheda di lavoro della matrice.' : 'Non collegata a una matrice dell'anagrafica.';
|
||
return '<div class="alert alert-light mb-0">' + msg + '</div>';
|
||
}
|
||
const src = h.mix_source === 'scheda' ? 'dall'ultima scheda di lavoro' : 'dall'anagrafica matrice (senza g/m)';
|
||
const rule = h.kind === 'm' ? 'Kg = metri × g/m ÷ 1000.' : 'I kg venduti si dividono in base alla quota.';
|
||
|
||
let html = '<div class="chart-hint mb-2">Mescole ' + src + '. ' + rule + '</div>';
|
||
html += '<div class="table-responsive"><table class="table table-striped align-middle mx-table mb-0"><thead><tr>';
|
||
html += '<th>Mescola</th><th class="text-end">g/m</th><th class="text-end">Quota</th><th class="text-end">€/kg</th>';
|
||
html += '<th class="text-end">Costo /' + ul + '</th><th class="text-end">Kg periodo</th><th class="text-end">Kg da produrre</th>';
|
||
html += '</tr></thead><tbody>';
|
||
d.mix.forEach(function(m) {
|
||
html += mixRow(m, cost);
|
||
});
|
||
if (cost.cost !== null && cost.cost !== undefined) {
|
||
html += '<tr class="table-light" style="cursor:default;"><td colspan="4" class="text-end fw-bold">Costo mescola totale</td>';
|
||
html += '<td class="text-end amount"><b>' + fmtPrice(cost.cost) + '</b></td><td colspan="2"></td></tr>';
|
||
}
|
||
html += '</tbody></table></div>';
|
||
html += '<div class="chart-hint mt-2">Costo = ' + (h.kind === 'm' ? 'g/m × €/kg ÷ 1000' : 'media dei €/kg pesata sulla quota') +
|
||
', prezzi medi degli ultimi ' + (cost.price_months || 3) + ' mesi pagati in Mago. Solo mescola: senza scarto, lavorazione, energia.</div>';
|
||
return html;
|
||
}
|
||
|
||
function mixRow(m, cost) {
|
||
const pt = (cost.parts || []).find(function(x) {
|
||
return x.id === m.id;
|
||
}) || {};
|
||
let eur;
|
||
if (pt.eur_kg !== null && pt.eur_kg !== undefined) {
|
||
eur = fmtNum(pt.eur_kg, 3);
|
||
if (pt.old) eur += ' <span class="tb tb-gm" title="Ultimo acquisto ' + fmtDate(pt.last_date) + '">vecchio</span>';
|
||
if (pt.check) eur += ' <span class="tb tb-check">da verificare</span>';
|
||
eur += '<div class="small text-muted">' + esc(pt.supplier || '') + '</div>';
|
||
} else {
|
||
eur = '<span class="tb tb-gm" title="Nessun acquisto abbinato: aggiungi il nome d'acquisto nella pagina mescole">senza prezzo</span>';
|
||
}
|
||
const gm = m.gm ? fmtNum(m.gm, 1) : '<span class="tb tb-gm">mancante</span>';
|
||
const part = (pt.part !== null && pt.part !== undefined) ? '<b>' + fmtPrice(pt.part) + '</b>' : '—';
|
||
const open = m.kg_open ? '<b style="color:#047857">' + fmtNum(m.kg_open) + ' kg</b>' : '—';
|
||
const out = m.out ? '<div class="small text-muted">' + esc(m.out) + '</div>' : '';
|
||
return '<tr style="cursor:default;">' +
|
||
'<td><b>' + esc(m.name) + '</b>' + out + '</td>' +
|
||
'<td class="text-end">' + gm + '</td>' +
|
||
'<td class="text-end">' + fmtNum(m.share, 1) + '%</td>' +
|
||
'<td class="text-end">' + eur + '</td>' +
|
||
'<td class="text-end amount">' + part + '</td>' +
|
||
'<td class="text-end amount">' + fmtNum(m.kg) + ' kg</td>' +
|
||
'<td class="text-end amount">' + open + '</td>' +
|
||
'</tr>';
|
||
}
|
||
|
||
function renderDetailChart(d) {
|
||
const ul = UOM_LABEL[d.head.kind] || d.head.uom;
|
||
const top = d.clients.slice(0, 8).map(c => c.code);
|
||
const by = {};
|
||
d.lines.forEach(l => {
|
||
if (l.price === null) return;
|
||
const k = top.includes(l.customer) ? l.customer : '_';
|
||
(by[k] = by[k] || []).push({
|
||
x: new Date(l.date + 'T12:00:00').getTime(),
|
||
y: l.price,
|
||
q: l.qty,
|
||
o: l.ord
|
||
});
|
||
});
|
||
const series = [];
|
||
top.forEach(c => {
|
||
if (by[c]) series.push({
|
||
name: (d.clients.find(x => x.code === c) || {}).name || c,
|
||
type: 'scatter',
|
||
data: by[c]
|
||
});
|
||
});
|
||
if (by._) series.push({
|
||
name: 'Altri clienti',
|
||
type: 'scatter',
|
||
data: by._
|
||
});
|
||
const nPts = series.length;
|
||
series.push({
|
||
name: 'Media mese',
|
||
type: 'line',
|
||
data: d.months.filter(m => m.price !== null).map(m => ({
|
||
x: new Date(m.ym + '-15T12:00:00').getTime(),
|
||
y: m.price
|
||
}))
|
||
});
|
||
const cm = ((d.cost || {}).months || []);
|
||
if (cm.length) series.push({
|
||
name: 'Costo mescola',
|
||
type: 'line',
|
||
data: cm.map(m => ({
|
||
x: new Date(m.ym + '-15T12:00:00').getTime(),
|
||
y: m.cost,
|
||
c: 1
|
||
}))
|
||
});
|
||
const n = nPts + 1; // indice+1 della linea "Media mese"
|
||
const isLine = i => i >= nPts;
|
||
upsert('detail', 'chDetail', {
|
||
chart: {
|
||
type: 'line',
|
||
height: 340,
|
||
fontFamily: 'inherit',
|
||
toolbar: {
|
||
show: false
|
||
},
|
||
zoom: {
|
||
enabled: false
|
||
},
|
||
animations: {
|
||
speed: 300
|
||
}
|
||
},
|
||
series: series,
|
||
colors: series.map((s, i) => i === n - 1 ? '#1f2d3d' : i === n ? '#dc2626' : PALETTE[i % PALETTE.length]),
|
||
stroke: {
|
||
width: series.map((s, i) => isLine(i) ? 2.5 : 0),
|
||
curve: series.map((s, i) => i === n ? 'stepline' : 'straight'),
|
||
dashArray: series.map((s, i) => i === n ? 6 : 0)
|
||
},
|
||
markers: {
|
||
size: series.map((s, i) => isLine(i) ? (i === n ? 0 : 3) : 7),
|
||
strokeWidth: 1,
|
||
strokeColors: '#fff',
|
||
hover: {
|
||
sizeOffset: 2
|
||
}
|
||
},
|
||
fill: {
|
||
opacity: series.map((s, i) => isLine(i) ? 1 : .8)
|
||
},
|
||
xaxis: {
|
||
type: 'datetime',
|
||
labels: {
|
||
datetimeUTC: false,
|
||
format: 'MMM yy'
|
||
}
|
||
},
|
||
yaxis: {
|
||
labels: {
|
||
formatter: v => fmtPrice(v)
|
||
},
|
||
title: {
|
||
text: '€/' + ul
|
||
}
|
||
},
|
||
legend: {
|
||
position: 'top',
|
||
horizontalAlign: 'left'
|
||
},
|
||
grid: {
|
||
borderColor: '#eef2f7'
|
||
},
|
||
tooltip: {
|
||
shared: false,
|
||
intersect: true,
|
||
custom: ({
|
||
seriesIndex,
|
||
dataPointIndex,
|
||
w
|
||
}) => {
|
||
const p = w.config.series[seriesIndex].data[dataPointIndex];
|
||
if (!p) return '';
|
||
const dt = new Date(p.x);
|
||
if (seriesIndex === n) return `<div style="padding:8px 12px;"><div style="font-weight:700;">Costo mescola ${MONTHS[dt.getMonth()]} ${dt.getFullYear()}</div><div><b>${fmtPrice(p.y)}/${ul}</b></div></div>`;
|
||
const lbl = seriesIndex === n - 1 ? `Media ${MONTHS[dt.getMonth()]} ${dt.getFullYear()}` : `${esc(w.config.series[seriesIndex].name)}<br><span style="color:#64748b">${fmtDate(iso(dt))} · ordine ${esc(p.o)}</span>`;
|
||
return `<div style="padding:8px 12px;"><div style="font-weight:700;">${lbl}</div><div><b>${fmtPrice(p.y)}/${ul}</b>${p.q ? ' · ' + qtyFmt(p.q) + ' ' + esc(d.head.uom) : ''}</div></div>`;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderDetailTables(d) {
|
||
const ul = UOM_LABEL[d.head.kind] || d.head.uom;
|
||
const nm = d.head.name;
|
||
makeTable('dClients', '#tdClients', {
|
||
data: d.clients,
|
||
pageLength: 10,
|
||
columns: [{
|
||
title: 'Cliente',
|
||
data: 'name',
|
||
render: (v, t, r) => t === 'display' ? `<b>${esc(v)}</b> <a href="sales_customer.php?c=${encodeURIComponent(r.code)}" title="Scheda cliente" onclick="event.stopPropagation()"><i class="fa-solid fa-address-card text-success"></i></a><div class="small text-muted">${esc(r.code)}</div>` : v
|
||
}, {
|
||
title: 'Ordini',
|
||
data: 'n',
|
||
className: 'text-end'
|
||
}, {
|
||
title: 'Quantità',
|
||
data: 'qty',
|
||
className: 'text-end text-nowrap',
|
||
render: (v, t, r) => t === 'display' ? qtyFmt(v) + (+r.open_qty ? `<div class="small text-muted">+${qtyFmt(r.open_qty)} da cons.</div>` : '') : +v
|
||
}, {
|
||
title: 'Valore',
|
||
data: 'value',
|
||
className: 'text-end amount',
|
||
render: (v, t) => t === 'display' ? fmtEur(v) : +v
|
||
}, {
|
||
title: 'Prezzo medio',
|
||
data: 'price',
|
||
className: 'text-end amount',
|
||
render: (v, t) => t === 'display' ? `<b>${fmtPrice(v)}</b><span class="small text-muted">/${ul}</span>` : (v === null ? '' : +v)
|
||
}, {
|
||
title: 'Rispetto alla media',
|
||
data: 'dev',
|
||
className: 'text-end',
|
||
render: (v, t) => {
|
||
if (t !== 'display') return v === null ? (t === 'export' ? '' : -1e9) : (t === 'export' ? v / 100 : +v);
|
||
if (v === null) return '—';
|
||
const c = v > 2 ? 'hi' : v < -2 ? 'lo' : 'eq';
|
||
return `<span class="dev-pill ${c}" title="${c === 'lo' ? 'Paga meno della media' : c === 'hi' ? 'Paga più della media' : 'In linea con la media'}">${v > 0 ? '+' : ''}${fmtNum(v, 1)}%</span>`;
|
||
}
|
||
}, {
|
||
title: 'Primo → ultimo prezzo',
|
||
data: 'last_price',
|
||
className: 'text-nowrap',
|
||
render: (v, t, r) => t === 'display' ? `${fmtPrice(r.first_price)} → <b>${fmtPrice(v)}</b><div class="small text-muted">${fmtDate(r.first_date)} → ${fmtDate(r.last_date)}</div>` : (v === null ? '' : +v)
|
||
}, {
|
||
title: '12 mesi',
|
||
data: 'var',
|
||
className: 'text-end',
|
||
render: (v, t) => t === 'display' ? pctSpan(v) : (v === null ? (t === 'export' ? '' : -1e9) : (t === 'export' ? v / 100 : +v))
|
||
}, {
|
||
title: 'Margine',
|
||
data: 'margin_pct',
|
||
className: 'text-end',
|
||
render: (v, t, r) => t === 'display' ? (v === null ? '<span class="text-muted">—</span>' : `${marginSpan(v)}<div class="small text-muted" title="Margine sull'ultimo prezzo">ultimo ${r.last_margin_pct === null ? '—' : fmtNum(r.last_margin_pct, 0) + '%'}</div>`) : (v === null ? (t === 'export' ? '' : -1e9) : (t === 'export' ? v / 100 : +v))
|
||
}],
|
||
order: [
|
||
[3, 'desc']
|
||
],
|
||
buttons: xls(`Clienti - ${nm}`)
|
||
});
|
||
makeTable('dLines', '#tdLines', {
|
||
data: d.lines,
|
||
pageLength: 10,
|
||
columns: [{
|
||
title: 'Data',
|
||
data: 'date',
|
||
render: (v, t) => t === 'display' ? fmtDate(v) : v
|
||
}, {
|
||
title: 'Ordine',
|
||
data: 'ord',
|
||
render: (v, t) => t === 'display' ? `<a class="ord-link" href="sales_orders_stats.php?q=${encodeURIComponent(v)}" target="_blank">${esc(v)}</a>` : v
|
||
}, {
|
||
title: 'Cliente',
|
||
data: 'cname'
|
||
}, {
|
||
title: 'Articolo',
|
||
data: 'item',
|
||
render: (v, t, r) => t === 'display' ? `<div class="fw-semibold">${esc(v)}</div><div class="small text-muted">${esc(r.description)}${r.raw ? ' · ' + esc(r.raw) : ''}</div>` : v
|
||
}, {
|
||
title: 'Quantità',
|
||
data: 'qty',
|
||
className: 'text-end text-nowrap',
|
||
render: (v, t, r) => t === 'display' ? qtyFmt(v) + (+r.open ? `<div class="small text-muted">${qtyFmt(r.open)} da cons.</div>` : '') : +v
|
||
}, {
|
||
title: 'Prezzo',
|
||
data: 'price',
|
||
className: 'text-end amount',
|
||
render: (v, t) => t === 'display' ? fmtPrice(v) : (v === null ? '' : +v)
|
||
}, {
|
||
title: 'Valore',
|
||
data: 'value',
|
||
className: 'text-end amount',
|
||
render: (v, t) => t === 'display' ? fmtEur(v, 2) : +v
|
||
}, {
|
||
title: 'Kg mescola',
|
||
data: 'kg',
|
||
className: 'text-end',
|
||
render: (v, t) => t === 'display' ? (+v ? fmtNum(v, 1) : '—') : +v
|
||
}],
|
||
order: [
|
||
[0, 'desc']
|
||
],
|
||
buttons: xls(`Righe ordine - ${nm}`)
|
||
});
|
||
}
|
||
|
||
function showDmTab(t) {
|
||
dmTab = t;
|
||
$('#dmTabs button').removeClass('active').filter(`[data-t="${t}"]`).addClass('active');
|
||
$('.dm-pane').hide().filter(`[data-p="${t}"]`).show();
|
||
try {
|
||
$.fn.dataTable.tables({
|
||
visible: true,
|
||
api: true
|
||
}).columns.adjust();
|
||
} catch (e) {}
|
||
}
|
||
|
||
|
||
/* ---------- EVENTI ---------- */
|
||
$(function() {
|
||
restore();
|
||
syncUi();
|
||
|
||
$('#fCustomers').select2({
|
||
theme: 'bootstrap-5',
|
||
placeholder: 'Tutti i clienti',
|
||
allowClear: true,
|
||
width: '100%',
|
||
closeOnSelect: false
|
||
})
|
||
.on('change', function() {
|
||
state.c = ($(this).val() || []);
|
||
clearTimeout(window._mxT);
|
||
window._mxT = setTimeout(load, 400);
|
||
});
|
||
|
||
$('#fPeriod').on('click', '.mxchip', function() {
|
||
state.p = $(this).data('p');
|
||
load();
|
||
});
|
||
$('#fFrom, #fTo').on('change', function() {
|
||
const f = $('#fFrom').val(),
|
||
t = $('#fTo').val();
|
||
if (!f || !t) return;
|
||
state.p = 'custom';
|
||
state.from = f;
|
||
state.to = t;
|
||
load();
|
||
});
|
||
$('#fType').on('click', '.mxchip', function() {
|
||
state.t = $(this).data('t');
|
||
syncUi();
|
||
save();
|
||
renderAll();
|
||
});
|
||
$('#fUom').on('click', '.mxchip', function() {
|
||
state.u = $(this).data('u');
|
||
if (state.u === 'm' || state.u === 'kg') state.mapU = state.u;
|
||
syncUi();
|
||
save();
|
||
renderAll();
|
||
});
|
||
$('#mapUom').on('click', 'button', function() {
|
||
state.mapU = $(this).data('u');
|
||
syncUi();
|
||
save();
|
||
renderMap();
|
||
});
|
||
$('#btnReset').on('click', function() {
|
||
state = {
|
||
p: '12m',
|
||
from: '',
|
||
to: '',
|
||
c: [],
|
||
t: 'all',
|
||
u: 'all',
|
||
mapU: 'm'
|
||
};
|
||
$('#fCustomers').val(null).trigger('change.select2');
|
||
load();
|
||
});
|
||
$('#kpis').on('click', '[data-goto-u]', function() {
|
||
$(`#fUom .mxchip[data-u="${$(this).data('goto-u')}"]`).trigger('click');
|
||
document.getElementById('tMat').scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'start'
|
||
});
|
||
}).on('click', '[data-goto-t]', function() {
|
||
$(`#fType .mxchip[data-t="${$(this).data('goto-t')}"]`).trigger('click');
|
||
document.getElementById('tMat').scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'start'
|
||
});
|
||
});
|
||
$('#onlyBanner').on('click', '#clearCust', function(e) {
|
||
e.preventDefault();
|
||
$('#fCustomers').val(null).trigger('change');
|
||
});
|
||
|
||
$('#tMat').on('click', 'tbody tr[data-k]', function() {
|
||
openDetail($(this).attr('data-k'));
|
||
});
|
||
$('#mgLow, #mgTop').on('click', '.mg-item', function() {
|
||
openDetail($(this).data('k'));
|
||
});
|
||
$('#mvUp, #mvDown').on('click', '.mv-item', function() {
|
||
openDetail($(this).data('k'));
|
||
});
|
||
$('#dmBody').on('click', '#dmTabs button', function() {
|
||
showDmTab($(this).data('t'));
|
||
});
|
||
document.getElementById('mxModal').addEventListener('hidden.bs.modal', () => {
|
||
const u = new URL(location.href);
|
||
u.searchParams.delete('k');
|
||
history.replaceState(null, '', u.toString());
|
||
if (charts.detail) {
|
||
charts.detail.destroy();
|
||
delete charts.detail;
|
||
}
|
||
});
|
||
|
||
loadFilters().catch(e => console.error('Filtri non caricati', e)).then(load).then(() => {
|
||
const k = new URLSearchParams(location.search).get('k');
|
||
if (k) openDetail(k);
|
||
});
|
||
});
|
||
})();
|
||
</script>
|
||
<script src="sales_help.js?v=6"></script>
|
||
<script src="ui_layout.js?v=1"></script>
|
||
</body>
|
||
|
||
</html>
|