Files
zibo-dashboard/public/userarea/sales_matrici.php
T
2026-10-27 12:14:07 +01:00

2200 lines
97 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
ob_start();
ini_set('display_errors', 1);
error_reporting(E_ALL);
include('include/headscript.php');
require_once __DIR__ . '/class/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 => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
} [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=&quot;mx-thumb ph&quot;><i class=&quot;fa-solid fa-shapes&quot;></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&#39;anagrafica.';
return '<div class="alert alert-light mb-0">' + msg + '</div>';
}
const src = h.mix_source === 'scheda' ? 'dall&#39;ultima scheda di lavoro' : 'dall&#39;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&#39;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&#39;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>