Files
zibo-dashboard/public/userarea/sales_orders_deliveries.php
T

1312 lines
55 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/UiPrefs.php';
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// --- Permessi: pagina visibile solo con 'sales.deliveries.view' ---
$canStats = userCan('sales.stats.view'); // per i tab e il link al dettaglio ordine
$canDeliveries = userCan('sales.deliveries.view');
if (!$canDeliveries) {
if (isset($_GET['action']) || isset($_POST['action'])) {
MagoSalesStats::jsonOut(['success' => false, 'ok' => false, 'message' => 'Non hai i permessi per questa pagina']);
}
while (ob_get_level()) ob_end_clean();
http_response_code(403);
echo '<div style="font-family:sans-serif;padding:60px;text-align:center;"><h3>Accesso non consentito</h3><p>Non hai i permessi per visualizzare questa pagina.</p><a href="production_dashboard.php">Torna alla Dashboard</a></div>';
exit;
}
/* ============================================================
* AJAX
* ============================================================ */
/** Filtri della pagina Consegne applicati alla vista righe aperte (alias r). */
function deliveriesWhere()
{
// cliente / agente / area dalla classe comune (scritti su x.) → riportati su r.
[$w, $p] = MagoSalesStats::buildWhere(['from' => '', 'to' => '']);
$w = preg_replace('/\bx\./', 'r.', $w);
$items = MagoSalesStats::arrParam('items');
if ($items) {
$ph = [];
foreach ($items as $i => $v) {
$ph[] = ':it' . $i;
$p['it' . $i] = $v;
}
$w .= ' AND r.item IN (' . implode(',', $ph) . ')';
}
$horizons = [
'late_zibo' => 'r.due_date < CURDATE()',
'late_client' => 'r.client_date < CURDATE()',
'week' => 'r.due_date >= CURDATE() AND YEARWEEK(r.due_date, 3) = YEARWEEK(CURDATE(), 3)',
'next_week' => 'YEARWEEK(r.due_date, 3) = YEARWEEK(CURDATE() + INTERVAL 7 DAY, 3)',
'7d' => 'r.due_date >= CURDATE() AND r.due_date <= CURDATE() + INTERVAL 7 DAY',
'30d' => 'r.due_date >= CURDATE() AND r.due_date <= CURDATE() + INTERVAL 30 DAY',
'nodate' => 'r.due_date IS NULL',
];
$h = $_GET['horizon'] ?? '';
if (isset($horizons[$h])) $w .= ' AND ' . $horizons[$h];
$q = trim((string)($_GET['search'] ?? ''));
if ($q !== '') {
$w .= ' AND (r.internal_ord_no LIKE :s1 OR r.company_name LIKE :s2 OR r.customer LIKE :s3 OR r.item LIKE :s4 OR r.description LIKE :s5 OR r.your_reference LIKE :s6)';
$like = '%' . $q . '%';
foreach (['s1', 's2', 's3', 's4', 's5', 's6'] as $k) $p[$k] = $like;
}
return [$w, $p];
}
$action = $_GET['action'] ?? '';
if ($action === 'filters') {
$opts = MagoSalesStats::filterOptions($pdo);
try {
$opts['items'] = $pdo->query("
SELECT item AS code, MAX(description) AS name, COUNT(*) AS n
FROM mago_sale_ord_detail
WHERE delivered = 0 AND cancelled = 0 AND item <> ''
GROUP BY item
ORDER BY item
")->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) {
$opts['items'] = [];
}
MagoSalesStats::jsonOut($opts);
}
if ($action === 'data') {
try {
$from = MagoSalesStats::openLinesFrom();
[$where, $params] = deliveriesWhere();
$kpi = MagoSalesStats::fetchAllQ($pdo, "
SELECT COUNT(*) AS n_lines,
COUNT(DISTINCT r.sale_ord_id) AS orders,
COALESCE(SUM(r.residual_value), 0) AS value,
SUM(r.due_date < CURDATE()) AS late_lines,
COALESCE(SUM(CASE WHEN r.due_date < CURDATE() THEN r.residual_value END), 0) AS late_value,
SUM(r.client_date < CURDATE()) AS late_client_lines,
COALESCE(SUM(CASE WHEN r.client_date < CURDATE() THEN r.residual_value END), 0) AS late_client_value,
SUM(r.due_date >= CURDATE() AND r.due_date <= CURDATE() + INTERVAL 7 DAY) AS due7_lines,
COALESCE(SUM(CASE WHEN r.due_date >= CURDATE() AND r.due_date <= CURDATE() + INTERVAL 7 DAY THEN r.residual_value END), 0) AS due7_value,
SUM(r.due_date IS NULL) AS nodate_lines,
AVG(CASE WHEN r.due_date < CURDATE() THEN DATEDIFF(CURDATE(), r.due_date) END) AS avg_late_days,
SUM(r.zibo_date IS NULL) AS no_zibo_lines
FROM $from
WHERE $where
", $params)[0];
// Carico per settimana (data ZIBO, se manca data cliente): scaduto + prossime 12 settimane + oltre + senza data
$weekly = MagoSalesStats::fetchAllQ($pdo, "
SELECT CASE
WHEN r.due_date IS NULL THEN 'nd'
WHEN r.due_date < CURDATE() THEN 'late'
WHEN r.due_date >= (CURDATE() - INTERVAL WEEKDAY(CURDATE()) DAY) + INTERVAL 12 WEEK THEN 'over'
ELSE DATE_FORMAT(r.due_date - INTERVAL WEEKDAY(r.due_date) DAY, '%Y-%m-%d')
END AS wk,
COUNT(*) AS n,
COALESCE(SUM(r.residual_value), 0) AS v
FROM $from
WHERE $where
GROUP BY wk
", $params);
// Clienti con più ritardo (data ZIBO)
$lateCustomers = MagoSalesStats::fetchAllQ($pdo, "
SELECT r.customer AS code, r.company_name AS name,
COUNT(*) AS n, COALESCE(SUM(r.residual_value), 0) AS v
FROM $from
WHERE $where AND r.due_date < CURDATE()
GROUP BY r.customer, r.company_name
ORDER BY v DESC
LIMIT 10
", $params);
$rows = MagoSalesStats::fetchAllQ($pdo, "
SELECT r.sale_ord_id, r.internal_ord_no, r.order_date, r.customer, r.company_name,
r.item, r.description, r.uom, r.qty, r.delivered_qty, r.residual_qty, r.residual_value,
r.zibo_date, r.client_date, r.due_date, r.salesperson, r.status,
CASE WHEN r.due_date < CURDATE() THEN DATEDIFF(CURDATE(), r.due_date) ELSE 0 END AS late_days,
CASE WHEN r.due_date >= CURDATE() THEN DATEDIFF(r.due_date, CURDATE()) END AS days_left
FROM $from
WHERE $where
ORDER BY (r.due_date IS NULL), r.due_date, r.internal_ord_no, r.line
LIMIT 5000
", $params);
MagoSalesStats::jsonOut([
'success' => true,
'today' => date('Y-m-d'),
'kpi' => $kpi,
'weekly' => $weekly,
'late_customers' => $lateCustomers,
'rows' => $rows,
]);
} 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>Consegne - <?= 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;
--c-zibo: #dc3545;
--c-client: #ea580c;
}
body {
font-size: 1.02rem;
background: #f8fafc;
}
.card {
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.back-dashboard {
background-color: #b9ebc7 !important;
color: var(--c-ink) !important;
border-radius: 10px;
font-weight: 600;
padding: 10px 18px;
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.stats-title {
display: flex;
align-items: center;
gap: 14px;
}
.stats-title-icon {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #198754, #3bb77e);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
box-shadow: 0 6px 16px rgba(25, 135, 84, .25);
}
.sync-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 12px;
border-radius: 999px;
background: #eef6ff;
color: #0b3d91;
font-size: .85rem;
font-weight: 600;
}
.sync-pill .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #94a3b8;
}
.sync-pill.ok .dot {
background: #22c55e;
}
.sync-pill.err .dot {
background: #ef4444;
}
.stats-tabs {
display: flex;
gap: 6px;
border-bottom: 2px solid var(--c-line);
margin-bottom: 18px;
flex-wrap: wrap;
}
.stats-tabs a {
padding: 10px 18px;
border-radius: 12px 12px 0 0;
font-weight: 700;
color: var(--c-muted);
text-decoration: none;
background: #f1f5f9;
margin-bottom: -2px;
border: 2px solid transparent;
}
.stats-tabs a:hover {
color: var(--c-ink);
}
.stats-tabs a.active {
background: #fff;
color: #198754;
border-color: var(--c-line);
border-bottom-color: #fff;
}
.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;
}
.chip-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.chip {
border: 1.5px solid var(--c-line);
background: #fff;
color: var(--c-ink);
border-radius: 999px;
padding: 6px 14px;
font-weight: 700;
font-size: .9rem;
cursor: pointer;
transition: all .15s ease;
}
.chip.active {
background: var(--chip-c, #198754);
border-color: var(--chip-c, #198754);
color: #fff;
}
.metric-switch {
display: inline-flex;
background: #f1f5f9;
border-radius: 12px;
padding: 4px;
gap: 4px;
}
.metric-switch button {
border: 0;
background: transparent;
padding: 6px 14px;
border-radius: 9px;
font-weight: 700;
color: var(--c-muted);
}
.metric-switch button.active {
background: #fff;
color: var(--c-ink);
box-shadow: 0 2px 6px rgba(15, 23, 42, .1);
}
.search-input {
border-radius: 12px !important;
border: 2px solid #198754 !important;
padding: 9px 14px !important;
}
.select2-container--bootstrap-5 .select2-selection {
border-radius: 10px;
min-height: 42px;
}
/* KPI pastello */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 14px;
}
@media (min-width: 1200px) {
.kpi-grid {
grid-template-columns: repeat(5, 1fr);
}
}
.kpi {
background: color-mix(in srgb, var(--k) 14%, #fff);
border: 1px solid color-mix(in srgb, var(--k) 28%, #fff);
border-radius: 16px;
padding: 16px 18px;
position: relative;
cursor: pointer;
transition: transform .15s ease, box-shadow .15s ease;
}
.kpi:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(15, 23, 42, .08);
}
.kpi.selected {
background: color-mix(in srgb, var(--k) 24%, #fff);
box-shadow: 0 0 0 2px var(--k) inset;
}
.kpi-label {
padding-right: 44px;
font-size: .78rem;
font-weight: 800;
color: color-mix(in srgb, var(--k) 70%, #1f2d3d);
text-transform: uppercase;
letter-spacing: .5px;
}
.kpi-value {
font-size: 1.75rem;
font-weight: 800;
color: var(--c-ink);
line-height: 1.15;
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.kpi-sub {
font-size: .85rem;
color: var(--c-muted);
margin-top: 2px;
}
.kpi-icon {
position: absolute;
right: 14px;
top: 14px;
width: 38px;
height: 38px;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
color: var(--k);
font-size: 1.05rem;
box-shadow: 0 2px 6px rgba(15, 23, 42, .06);
}
.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);
}
/* date */
.dt-zibo {
font-weight: 700;
color: var(--c-ink);
white-space: nowrap;
}
.dt-zibo.late {
color: var(--c-zibo);
}
.dt-client {
font-size: .82rem;
color: #64748b;
white-space: nowrap;
}
.dt-client.late {
color: var(--c-client);
font-weight: 700;
}
.dt-tag {
display: inline-block;
font-size: .68rem;
font-weight: 800;
letter-spacing: .3px;
text-transform: uppercase;
margin-right: 4px;
opacity: .75;
}
.dt-fallback {
font-size: .72rem;
color: #94a3b8;
font-style: italic;
}
.week-badge {
display: inline-block;
background: #fef3c7;
color: #b45309;
font-size: .66rem;
font-weight: 800;
border-radius: 999px;
padding: 1px 7px;
margin-left: 5px;
vertical-align: middle;
}
.late-days {
display: inline-block;
background: #fde8ea;
color: var(--c-zibo);
font-weight: 800;
font-size: .8rem;
padding: 3px 9px;
border-radius: 999px;
white-space: nowrap;
}
.days-left {
font-size: .8rem;
color: var(--c-muted);
white-space: nowrap;
}
.days-left.soon {
color: #b45309;
font-weight: 700;
}
#linesTable thead th {
background: #b9ebc7;
color: var(--c-ink);
white-space: nowrap;
}
#linesTable td {
vertical-align: middle;
}
.amount {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.legend-dates {
display: flex;
gap: 16px;
flex-wrap: wrap;
font-size: .85rem;
color: var(--c-muted);
}
.legend-dates b.z {
color: var(--c-zibo);
}
.legend-dates b.c {
color: var(--c-client);
}
.warn-strip {
background: #fff8e6;
border: 1px solid #ffe2a8;
color: #8a5a00;
border-radius: 12px;
padding: 10px 14px;
font-size: .9rem;
}
.dataTables_wrapper .dt-buttons .btn {
border-radius: 10px;
font-weight: 600;
}
.order-link {
font-weight: 800;
color: #0d6efd;
text-decoration: none;
}
.order-link:hover {
text-decoration: underline;
}
.sc-link {
color: #198754;
margin-left: 6px;
opacity: .75;
text-decoration: none;
}
.sc-link:hover {
opacity: 1;
}
</style>
</head>
<body>
<div class="wrapper toggled" id="appWrapper">
<?php include('include/navbar.php'); ?>
<?php include('include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<div class="card p-3">
<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-truck-fast"></i></div>
<div>
<h5 class="mb-1" style="font-weight:800;">Statistiche Ordini Cliente</h5>
<span class="sync-pill" id="syncPill"><span class="dot"></span><span id="syncText">Dati Mago4</span></span>
</div>
</div>
<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">
<?php if ($canStats) : ?>
<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>
<?php if (userCan('sales.matrici.view')) : ?>
<a href="sales_matrici.php"><i class="fa-solid fa-shapes me-1"></i> Matrici</a>
<?php endif; ?>
<?php endif; ?>
<a href="sales_orders_deliveries.php" class="active"><i class="fa-solid fa-truck-fast me-1"></i> Consegne</a>
<button type="button" class="sh-page ms-auto align-self-center mb-1" data-help-open="page.deliveries"><i class="fa-regular fa-circle-question me-1"></i> Come si legge</button>
</div>
<!-- FILTRI -->
<div class="filters-box mb-4">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
<div>
<span class="flabel mb-1" data-help="dl.horizon">Scadenza</span>
<div class="chip-row" id="horizonChips">
<button type="button" class="chip active" data-h="">Tutte</button>
<button type="button" class="chip" data-h="late_zibo" style="--chip-c:var(--c-zibo);">Scadute (data ZIBO)</button>
<button type="button" class="chip" data-h="late_client" style="--chip-c:var(--c-client);">Oltre data cliente</button>
<button type="button" class="chip" data-h="week" style="--chip-c:#b45309;">Questa settimana</button>
<button type="button" class="chip" data-h="next_week" style="--chip-c:#ca8a04;">Prossima settimana</button>
<button type="button" class="chip" data-h="30d" style="--chip-c:#0d6efd;">Entro 30 gg</button>
<button type="button" class="chip" data-h="nodate" style="--chip-c:#64748b;">Senza data</button>
</div>
</div>
<div class="legend-dates">
<span><b class="z">Data ZIBO</b> = settimana di consegna comunicata (rossa a settimana finita)</span>
<span><b class="c">Data cliente</b> = desiderata (arancione se superata)</span>
</div>
</div>
<div class="row g-2 align-items-end mt-1">
<div class="col-12 col-md-3">
<label class="flabel" for="fCustomers">Clienti</label>
<select id="fCustomers" multiple style="width:100%;"></select>
</div>
<div class="col-12 col-md-3">
<label class="flabel" for="fItems">Articoli</label>
<select id="fItems" multiple style="width:100%;"></select>
</div>
<div class="col-6 col-md-2">
<label class="flabel" for="fSalespeople">Agente</label>
<select id="fSalespeople" multiple style="width:100%;"></select>
</div>
<div class="col-6 col-md-1">
<label class="flabel" for="fAreas">Area</label>
<select id="fAreas" multiple style="width:100%;"></select>
</div>
<div class="col-12 col-md-3 d-flex gap-2">
<input type="text" id="fSearch" class="form-control search-input" placeholder="🔎 Ordine, cliente, articolo…">
<button type="button" class="btn btn-outline-secondary" id="btnReset" title="Azzera filtri" style="border-radius:12px;">
<i class="fa-solid fa-eraser"></i>
</button>
</div>
</div>
</div>
<div id="content">
<div class="warn-strip mb-3" id="noZibo" style="display:none;"></div>
<!-- KPI -->
<div class="kpi-grid mb-4" id="kpiGrid">
<div class="kpi" data-h="" style="--k:#198754;">
<div class="kpi-icon"><i class="fa-solid fa-boxes-stacked"></i></div>
<div class="kpi-label" data-help="dl.total">Da consegnare</div>
<div class="kpi-value" id="kValue">—</div>
<div class="kpi-sub" id="kValueSub">&nbsp;</div>
</div>
<div class="kpi" data-h="late_zibo" style="--k:var(--c-zibo);">
<div class="kpi-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
<div class="kpi-label" data-help="dl.lateZibo">Scadute (data ZIBO)</div>
<div class="kpi-value" id="kLate">—</div>
<div class="kpi-sub" id="kLateSub">&nbsp;</div>
</div>
<div class="kpi" data-h="late_client" style="--k:var(--c-client);">
<div class="kpi-icon"><i class="fa-solid fa-user-clock"></i></div>
<div class="kpi-label" data-help="dl.lateClient">Oltre data cliente</div>
<div class="kpi-value" id="kLateClient">—</div>
<div class="kpi-sub" id="kLateClientSub">&nbsp;</div>
</div>
<div class="kpi" data-h="7d" style="--k:#b45309;">
<div class="kpi-icon"><i class="fa-solid fa-calendar-week"></i></div>
<div class="kpi-label" data-help="dl.next7">Prossimi 7 giorni</div>
<div class="kpi-value" id="kDue7">—</div>
<div class="kpi-sub" id="kDue7Sub">&nbsp;</div>
</div>
<div class="kpi" data-h="nodate" style="--k:#64748b;">
<div class="kpi-icon"><i class="fa-solid fa-calendar-xmark"></i></div>
<div class="kpi-label" data-help="dl.nodate">Senza data</div>
<div class="kpi-value" id="kNoDate">—</div>
<div class="kpi-sub">righe senza data di consegna</div>
</div>
</div>
<div class="d-flex justify-content-end mb-2"><button type="button" data-layout-btn="sales_orders_deliveries"></button></div>
<!-- RIQUADRI (ordine personalizzabile per utente: ui_layout.js) -->
<div class="row g-3 mb-3" <?= UiPrefs::attr($pdo, $iduserlogin ?? 0, 'sales_orders_deliveries') ?>>
<div class="col-xl-8" data-widget="weekly" data-title="Carico consegne per settimana">
<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="dl.weekly">Carico consegne per settimana</div>
<div class="chart-hint">Per data ZIBO (se manca: data cliente) · clicca una barra per filtrare</div>
</div>
<div class="metric-switch" id="metricSwitch">
<button type="button" data-metric="value" class="active">€ Valore</button>
<button type="button" data-metric="count"># Righe</button>
</div>
</div>
<div id="chWeekly" style="min-height:340px;"></div>
</div>
</div>
<div class="col-xl-4" data-widget="latecust" data-title="Clienti con più ritardo">
<div class="chart-card">
<div class="chart-title" data-help="dl.lateCust">Clienti con più ritardo</div>
<div class="chart-hint">Valore residuo scaduto (data ZIBO) · clicca per filtrare</div>
<div id="chLateCust" style="min-height:340px;"></div>
</div>
</div>
<div class="col-12" data-widget="rows" data-title="Righe da consegnare">
<div class="chart-card">
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2">
<div class="chart-title" data-help="dl.rows">Righe da consegnare <span class="text-muted fw-normal" id="rowsInfo"></span></div>
<div class="chart-hint">Ordinate per scadenza<?= $canStats ? ' · clicca il numero ordine per aprirlo' : '' ?></div>
</div>
<div class="table-responsive">
<table id="linesTable" class="table table-striped align-middle w-100">
<thead>
<tr>
<th>Ordine</th>
<th>Cliente</th>
<th>Articolo</th>
<th class="text-end">Residuo</th>
<th>Consegna (ZIBO / cliente)</th>
<th>Ritardo</th>
<th class="text-end">Valore residuo</th>
<th>Agente</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
<?php include('jsinclude.php'); ?>
<script>
// Navbar collassata all'apertura: si espande al passaggio del mouse
$(function() {
$('.sidebar-wrapper').hover(
function() {
$('.wrapper.toggled').addClass('sidebar-hovered');
},
function() {
$('.wrapper').removeClass('sidebar-hovered');
}
);
});
</script>
<script>
(function() {
const STORE_KEY = 'zibo_sales_deliveries_filters_v1';
const CAN_STATS = <?= $canStats ? 'true' : 'false' ?>; // il link al dettaglio ordine porta alle statistiche
const charts = {};
let table = null;
let metric = 'value';
let horizon = '';
let loadTimer = null;
let seq = 0;
/* ---------------- UTILS ---------------- */
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
} [c]));
}
const fmtNum = n => Number(n || 0).toLocaleString('it-IT');
const fmtQty = n => Number(Math.round(+n * 100) / 100).toLocaleString('it-IT');
const fmtEur = (n, d) => Number(n || 0).toLocaleString('it-IT', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: d || 0,
maximumFractionDigits: d || 0
});
function fmtEurShort(n) {
const v = Number(n || 0),
a = Math.abs(v);
if (a >= 1e6) return (v / 1e6).toLocaleString('it-IT', {
maximumFractionDigits: 2
}) + ' M€';
if (a >= 1e4) return (v / 1e3).toLocaleString('it-IT', {
maximumFractionDigits: 1
}) + ' k€';
return fmtEur(v);
}
function fmtDate(d) {
if (!d) return '';
const p = String(d).substring(0, 10).split('-');
return p.length === 3 ? `${p[2]}/${p[1]}/${p[0]}` : d;
}
function iso(d) {
const z = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`;
}
const fmtMetric = v => metric === 'value' ? fmtEur(v) : fmtNum(Math.round(v));
const fmtAxis = v => metric === 'value' ? fmtEurShort(v) : fmtNum(Math.round(v));
// Data ZIBO = settimana di consegna (impostazioni in MagoSalesStats)
const ZIBO_WEEK = <?= MagoSalesStats::ZIBO_IS_WEEK ? 'true' : 'false' ?>;
const ZIBO_END_DAYS = <?= (int)MagoSalesStats::ZIBO_WEEK_END_DAYS ?>;
// scadenza della data ZIBO: fine della settimana indicata
function ziboEnd(z) {
if (!z || !ZIBO_WEEK) return z;
const d = new Date(String(z).substring(0, 10) + 'T00:00:00');
d.setDate(d.getDate() - ((d.getDay() + 6) % 7) + ZIBO_END_DAYS);
return iso(d);
}
// stato della data ZIBO: 'late' (settimana finita), 'week' (settimana in corso), '' (futura)
function ziboState(z, open) {
if (!z || !open) return '';
const today = iso(new Date());
if (ziboEnd(z) < today) return 'late';
if (ZIBO_WEEK && String(z).substring(0, 10) <= today) return 'week';
return '';
}
function ziboHtml(z, open, extra) {
const st = ziboState(z, open);
const title = ZIBO_WEEK ? ` title="Settimana del ${fmtDate(z)}: consegna entro ${fmtDate(ziboEnd(z))}"` : '';
return `<div class="dt-zibo${st === 'late' ? ' late' : ''}"${title}><span class="dt-tag">ZIBO${ZIBO_WEEK ? ' sett.' : ''}</span>${fmtDate(z)}` +
(st === 'week' ? '<span class="week-badge">IN SETTIMANA</span>' : '') + (extra || '') + '</div>';
}
function dateCell(zibo, client) {
const today = iso(new Date());
let h = zibo ? ziboHtml(zibo, true) : '<div class="dt-fallback">data ZIBO non indicata</div>';
if (client) h += `<div class="dt-client${client < today ? ' late' : ''}"><span class="dt-tag">Cliente</span>${fmtDate(client)}</div>`;
return h;
}
/* ---------------- FILTRI ---------------- */
function save() {
try {
localStorage.setItem(STORE_KEY, JSON.stringify({
horizon,
metric,
customers: $('#fCustomers').val() || [],
items: $('#fItems').val() || [],
salespeople: $('#fSalespeople').val() || [],
areas: $('#fAreas').val() || [],
search: $('#fSearch').val()
}));
} catch (e) {}
}
function restore() {
try {
return JSON.parse(localStorage.getItem(STORE_KEY) || 'null');
} catch (e) {
return null;
}
}
function scheduleLoad(d) {
clearTimeout(loadTimer);
loadTimer = setTimeout(loadData, d === undefined ? 300 : d);
}
function setHorizon(h) {
horizon = h || '';
$('#horizonChips .chip').removeClass('active');
$(`#horizonChips .chip[data-h="${horizon}"]`).addClass('active');
$('.kpi').removeClass('selected');
if (horizon) $(`.kpi[data-h="${horizon}"]`).addClass('selected');
scheduleLoad(0);
}
function renderSyncInfo(s) {
const $p = $('#syncPill').removeClass('ok err');
const o = s && s.sale_ord_details ? s.sale_ord_details : (s && s.sale_ord);
if (!s || s.error || !o || !o.last_run_at) {
$('#syncText').text(s && s.error ? s.error : 'Righe ordine mai sincronizzate');
if (s && s.error) $p.addClass('err');
return;
}
$p.addClass(o.last_status === 'ok' ? 'ok' : 'err');
$('#syncText').text(`Aggiornato il ${o.last_run_at}`);
}
function loadFilters() {
return fetch('?action=filters').then(r => r.json()).then(d => {
renderSyncInfo(d.sync);
const fill = (id, list, label) => {
const $s = $(id).empty();
(list || []).forEach(o => $s.append(new Option(label(o), o.code, false, false)));
};
fill('#fCustomers', d.customers, o => `${o.name} (${o.code})`);
fill('#fItems', d.items, o => `${o.code} · ${o.name || ''}`);
fill('#fSalespeople', d.salespeople, o => o.code);
fill('#fAreas', d.areas, o => o.code);
});
}
/* ---------------- DATI ---------------- */
function loadData() {
save();
const p = new URLSearchParams({
action: 'data'
});
if (horizon) p.append('horizon', horizon);
const s = $('#fSearch').val().trim();
if (s) p.append('search', s);
[
['customers', '#fCustomers'],
['items', '#fItems'],
['salespeople', '#fSalespeople'],
['areas', '#fAreas']
].forEach(([k, id]) => ($(id).val() || []).forEach(v => p.append(k + '[]', v)));
const my = ++seq;
$('#content').css('opacity', .55);
fetch('?' + p.toString()).then(r => r.json()).then(d => {
if (my !== seq) return;
$('#content').css('opacity', 1);
if (!d.success) {
alert(d.message || 'Errore');
return;
}
renderKpi(d.kpi);
renderWeekly(d.weekly);
renderLateCustomers(d.late_customers);
renderTable(d.rows);
}).catch(e => {
$('#content').css('opacity', 1);
console.error(e);
});
}
function renderKpi(k) {
$('#kValue').text(fmtEurShort(k.value)).attr('title', fmtEur(k.value, 2));
$('#kValueSub').text(`${fmtNum(k.n_lines)} righe · ${fmtNum(k.orders)} ordini`);
$('#kLate').text(fmtNum(k.late_lines) + ' righe');
$('#kLateSub').html(`<b style="color:var(--c-zibo);">${fmtEurShort(k.late_value)}</b>` +
(k.avg_late_days ? ` · ritardo medio ${Math.round(k.avg_late_days)} gg` : ''));
$('#kLateClient').text(fmtNum(k.late_client_lines) + ' righe');
$('#kLateClientSub').html(`<b style="color:var(--c-client);">${fmtEurShort(k.late_client_value)}</b> desiderata superata`);
$('#kDue7').text(fmtNum(k.due7_lines) + ' righe');
$('#kDue7Sub').text(fmtEurShort(k.due7_value) + ' in scadenza');
$('#kNoDate').text(fmtNum(k.nodate_lines));
const nz = +k.no_zibo_lines || 0;
$('#noZibo').toggle(nz > 0).html(nz > 0 ?
`<i class="fa-solid fa-circle-info me-1"></i> ${fmtNum(nz)} righe senza data ZIBO: per queste la scadenza è la data cliente.` : '');
}
function upsert(key, el, opts) {
if (charts[key]) charts[key].updateOptions(opts, true, true);
else {
charts[key] = new ApexCharts(document.getElementById(el), opts);
charts[key].render();
}
}
const base = {
fontFamily: 'inherit',
toolbar: {
show: false
},
animations: {
speed: 350
}
};
function renderWeekly(rows) {
const map = {};
rows.forEach(r => map[r.wk] = r);
const today = new Date();
const monday = new Date(today);
monday.setDate(today.getDate() - ((today.getDay() + 6) % 7));
const cats = [{
key: 'late',
label: 'Scaduto',
color: '#dc3545',
h: 'late_zibo'
}];
for (let i = 0; i < 12; i++) {
const d = new Date(monday);
d.setDate(monday.getDate() + i * 7);
cats.push({
key: iso(d),
label: i === 0 ? 'Questa sett.' : `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`,
color: i === 0 ? '#b45309' : (i === 1 ? '#ca8a04' : '#0d6efd'),
h: i === 0 ? 'week' : (i === 1 ? 'next_week' : '')
});
}
cats.push({
key: 'over',
label: 'Oltre',
color: '#64748b',
h: ''
}, {
key: 'nd',
label: 'Senza data',
color: '#cbd5e1',
h: 'nodate'
});
upsert('weekly', 'chWeekly', {
chart: {
...base,
type: 'bar',
height: 340,
events: {
dataPointSelection: (e, ctx, cfg) => {
const c = cats[cfg.dataPointIndex];
if (c && c.h) setHorizon(c.h);
}
}
},
series: [{
name: metric === 'value' ? 'Valore residuo' : 'Righe',
data: cats.map(c => map[c.key] ? (metric === 'value' ? +map[c.key].v : +map[c.key].n) : 0)
}],
colors: cats.map(c => c.color),
plotOptions: {
bar: {
distributed: true,
borderRadius: 5,
columnWidth: '60%'
}
},
legend: {
show: false
},
dataLabels: {
enabled: false
},
xaxis: {
categories: cats.map(c => c.label),
labels: {
rotate: -45
}
},
yaxis: {
labels: {
formatter: fmtAxis
}
},
tooltip: {
y: {
formatter: fmtMetric
}
},
grid: {
borderColor: '#eef2f7'
}
});
}
function renderLateCustomers(rows) {
upsert('latecust', 'chLateCust', {
chart: {
...base,
type: 'bar',
height: 340,
events: {
dataPointSelection: (e, ctx, cfg) => {
const r = rows[cfg.dataPointIndex];
if (r) $('#fCustomers').val([r.code]).trigger('change');
}
}
},
series: [{
name: 'Valore scaduto',
data: rows.map(r => +r.v)
}],
colors: ['#dc3545'],
plotOptions: {
bar: {
horizontal: true,
borderRadius: 4,
barHeight: '65%'
}
},
dataLabels: {
enabled: false
},
xaxis: {
categories: rows.map(r => (r.name || r.code).length > 26 ? (r.name || r.code).substring(0, 24) + '…' : (r.name || r.code)),
labels: {
formatter: v => fmtEurShort(v)
}
},
tooltip: {
custom: ({
dataPointIndex
}) => {
const r = rows[dataPointIndex];
return r ? `<div style="padding:8px 12px;font-size:.85rem;"><b>${esc(r.name)}</b><br>${fmtNum(r.n)} righe scadute · <b>${fmtEur(r.v)}</b></div>` : '';
}
},
grid: {
borderColor: '#eef2f7'
},
noData: {
text: 'Nessuna riga scaduta 🎉'
}
});
}
function renderTable(rows) {
$('#rowsInfo').text(rows.length >= 5000 ? '(prime 5.000: restringi i filtri)' : `(${fmtNum(rows.length)})`);
const data = rows.map(r => {
const late = +r.late_days > 0;
const left = r.days_left !== null && r.days_left !== undefined ? +r.days_left : null;
return [
(CAN_STATS ?
`<a class="order-link" href="sales_orders_stats.php?q=${encodeURIComponent(r.internal_ord_no)}" target="_blank">${esc(r.internal_ord_no)}</a>` :
`<b>${esc(r.internal_ord_no)}</b>`) + `<div class="small text-muted">${fmtDate(r.order_date)}</div>`,
`<div class="fw-semibold">${esc(r.company_name)}</div><div class="small text-muted">${esc(r.customer)}${CAN_STATS ? `<a class="sc-link" href="sales_customer.php?c=${encodeURIComponent(r.customer)}" title="Apri la scheda cliente" onclick="event.stopPropagation();"><i class="fa-solid fa-address-card"></i></a>` : ''}</div>`,
`<b>${esc(r.item)}</b><div class="small text-muted" style="white-space:normal;max-width:300px;">${esc(r.description)}</div>`,
`<span data-order="${+r.residual_qty}" class="amount"><b>${fmtQty(r.residual_qty)}</b> ${esc(r.uom)}</span><div class="small text-muted">di ${fmtQty(r.qty)}</div>`,
`<span data-order="${esc(r.due_date || '9999-12-31')}">${dateCell(r.zibo_date, r.client_date)}</span>`,
`<span data-order="${late ? +r.late_days : (left !== null ? -left : -99999)}">` +
(late ? `<span class="late-days">+${fmtNum(r.late_days)} gg</span>` :
(left !== null ? `<span class="days-left${left <= 7 ? ' soon' : ''}">tra ${fmtNum(left)} gg</span>` : '<span class="text-muted">—</span>')) + '</span>',
`<span data-order="${+r.residual_value}" class="amount">${fmtEur(r.residual_value, 2)}</span>`,
esc(r.salesperson)
];
});
if (table) {
table.clear().rows.add(data).draw(false);
return;
}
// ordinamento sul valore in data-order del primo elemento della cella
const sortable = (type) => (d, t) => {
if (t !== 'sort' && t !== 'type') return d;
const v = $(d).first().data('order');
return type === 'num' ? (+v || 0) : (v || '');
};
table = $('#linesTable').DataTable({
data: data,
columns: [{}, {}, {}, {
className: 'text-end',
orderSequence: ['desc', 'asc']
}, {}, {
orderSequence: ['desc', 'asc']
}, {
className: 'text-end',
orderSequence: ['desc', 'asc']
}, {}],
columnDefs: [{
targets: [3, 5, 6],
type: 'num',
render: sortable('num')
}, {
targets: [4],
type: 'string',
render: sortable('str')
}],
order: [
[4, 'asc']
],
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
dom: "<'row mb-2'<'col-md-6'B><'col-md-6'f>>rt<'row mt-2'<'col-md-5'i><'col-md-7'p>>",
buttons: {
dom: {
button: {
className: 'btn'
}
},
buttons: [{
extend: 'excelHtml5',
text: '<i class="fa-solid fa-file-excel me-1"></i> Excel',
className: 'btn btn-outline-success btn-sm',
title: 'Righe da consegnare',
exportOptions: {
format: {
body: (d, row, col) => {
if (col === 6) return +$(d).data('order');
return $('<div>').html(String(d).replace(/<(div|span)/g, ' <$1').replace(/<\/span>/g, '</span> ')).text().replace(/\s+/g, ' ').trim();
}
}
}
}]
},
language: {
url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json'
}
});
}
/* ---------------- EVENTI ---------------- */
$(function() {
[
['#fCustomers', 'Tutti i clienti'],
['#fItems', 'Tutti gli articoli'],
['#fSalespeople', 'Tutti'],
['#fAreas', 'Tutte']
].forEach(([id, ph]) => {
$(id).select2({
theme: 'bootstrap-5',
placeholder: ph,
allowClear: true,
closeOnSelect: false,
width: '100%'
}).on('change', () => scheduleLoad());
});
$('#horizonChips').on('click', '.chip', function() {
setHorizon($(this).data('h'));
});
$('#kpiGrid').on('click', '.kpi', function() {
const h = $(this).data('h') || '';
setHorizon(horizon === h ? '' : h);
});
$('#metricSwitch').on('click', 'button', function() {
metric = $(this).data('metric');
$('#metricSwitch button').removeClass('active');
$(this).addClass('active');
scheduleLoad(0);
});
$('#fSearch').on('input', () => scheduleLoad(450));
$('#btnReset').on('click', function() {
$('#fCustomers, #fItems, #fSalespeople, #fAreas').val(null).trigger('change.select2');
$('#fSearch').val('');
setHorizon('');
});
loadFilters().then(() => {
const s = restore();
if (s) {
metric = s.metric === 'count' ? 'count' : 'value';
$('#metricSwitch button').removeClass('active');
$(`#metricSwitch button[data-metric="${metric}"]`).addClass('active');
$('#fCustomers').val(s.customers || []).trigger('change.select2');
$('#fItems').val(s.items || []).trigger('change.select2');
$('#fSalespeople').val(s.salespeople || []).trigger('change.select2');
$('#fAreas').val(s.areas || []).trigger('change.select2');
$('#fSearch').val(s.search || '');
horizon = s.horizon || '';
}
setHorizon(horizon);
});
});
})();
</script>
<script src="sales_help.js?v=3"></script>
<script src="ui_layout.js?v=1"></script>
</body>
</html>