Files
zibo-dashboard/public/userarea/sales_customer.php
T
2026-10-22 13:56:39 +02:00

1935 lines
72 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/UiPrefs.php';
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// --- Permessi: pagina visibile solo con 'sales.stats.view' ---
if (!userCan('sales.stats.view')) {
if (isset($_GET['action']) || isset($_POST['action'])) {
MagoSalesStats::jsonOut(['success' => false, 'ok' => false, 'message' => 'Non hai i permessi per questa pagina']);
}
while (ob_get_level()) ob_end_clean();
http_response_code(403);
echo '<div style="font-family:sans-serif;padding:60px;text-align:center;"><h3>Accesso non consentito</h3><p>Non hai i permessi per visualizzare questa pagina.</p><a href="production_dashboard.php">Torna alla Dashboard</a></div>';
exit;
}
$canDeliveries = userCan('sales.deliveries.view');
/* ============================================================
* AJAX
* ============================================================ */
$action = $_GET['action'] ?? '';
if ($action === 'filters') {
MagoSalesStats::jsonOut(MagoSalesStats::filterOptions($pdo));
}
if ($action === 'data') {
try {
$c = trim((string)($_GET['c'] ?? ''));
if ($c === '') MagoSalesStats::jsonOut(['success' => false, 'message' => 'Cliente non indicato']);
$p = ['c' => $c];
$curY = (int)date('Y');
$from = MagoSalesStats::baseFrom();
$val = "CASE WHEN x.status <> 'annullato' THEN x.taxable_amount ELSE 0 END";
// --- anagrafica ---
$st = $pdo->prepare("SELECT cust_supp AS code, company_name AS name, city, county, region, country
FROM mago_cust_supp WHERE cust_supp_type = " . MagoSalesStats::CUSTOMER_TYPE . " AND cust_supp = ?");
$st->execute([$c]);
$info = $st->fetch(PDO::FETCH_ASSOC) ?: ['code' => $c, 'name' => $c, 'city' => '', 'county' => '', 'region' => '', 'country' => ''];
// --- ordini del cliente (dal 2020, "listino" esclusi) ---
$orders = MagoSalesStats::fetchAllQ($pdo, "
SELECT x.sale_ord_id, x.internal_ord_no, x.external_ord_no, x.your_reference, x.order_date,
x.salesperson, x.area, x.status, x.invoiced, x.taxable_amount, x.is_late,
x.open_lines, x.late_lines, x.zibo_date, x.client_date, (x.summary_tb_modified IS NULL) AS no_amount
FROM $from
WHERE x.customer = :c
ORDER BY x.order_date DESC, x.sale_ord_id DESC
", $p);
if (!$orders) {
$hasInv = false;
try {
$q = $pdo->prepare("SELECT 1 FROM mago_sale_doc WHERE customer = ? LIMIT 1");
$q->execute([$c]);
$hasInv = (bool)$q->fetchColumn();
} catch (Throwable $e) {
}
if (!$hasInv) MagoSalesStats::jsonOut(['success' => false, 'message' => 'Nessun dato per questo cliente']);
}
$last = $orders[0] ?? null;
$firstDate = $orders ? end($orders)['order_date'] : null;
// ritmo d'ordine (ultimi N anni, annullati esclusi)
$limit = date('Y-m-d', strtotime('-' . MagoSalesStats::DORMANT_WINDOW_YEARS . ' years'));
$dates = [];
foreach ($orders as $o) {
if ($o['status'] !== 'annullato' && $o['order_date'] >= $limit) $dates[] = $o['order_date'];
}
$rhythm = MagoSalesStats::rhythmInfo($dates);
// --- ordinato per anno (totale e a pari data) ---
$ordYears = MagoSalesStats::fetchAllQ($pdo, "
SELECT YEAR(x.order_date) AS y, SUM($val) AS full, SUM(x.status <> 'annullato') AS n,
SUM(CASE WHEN DATE_FORMAT(x.order_date, '%m-%d') <= DATE_FORMAT(CURDATE(), '%m-%d') THEN $val ELSE 0 END) AS ytd
FROM $from WHERE x.customer = :c GROUP BY y ORDER BY y
", $p);
$since24 = date('Y-m-01', strtotime('first day of -23 months'));
$ordMonths = MagoSalesStats::fetchAllQ($pdo, "
SELECT DATE_FORMAT(x.order_date, '%Y-%m') AS ym, SUM($val) AS v
FROM $from WHERE x.customer = :c AND x.order_date >= '$since24'
GROUP BY ym ORDER BY ym
", $p);
// --- fatturato (se le fatture sono sincronizzate) ---
$inv = ['available' => false, 'years' => [], 'months' => [], 'docs' => [], 'v12' => 0, 'cn12' => 0, 'cn12_n' => 0];
try {
$iFrom = MagoSalesStats::invoiceFrom();
$inv['available'] = (bool)$pdo->query("SELECT 1 FROM mago_sale_doc LIMIT 1")->fetchColumn();
if ($inv['available']) {
$inv['years'] = MagoSalesStats::fetchAllQ($pdo, "
SELECT YEAR(i.document_date) AS y, SUM(i.revenue) AS full,
SUM(CASE WHEN DATE_FORMAT(i.document_date, '%m-%d') <= DATE_FORMAT(CURDATE(), '%m-%d') THEN i.revenue ELSE 0 END) AS ytd
FROM $iFrom WHERE i.customer = :c GROUP BY y ORDER BY y
", $p);
$inv['months'] = MagoSalesStats::fetchAllQ($pdo, "
SELECT DATE_FORMAT(i.document_date, '%Y-%m') AS ym, SUM(i.revenue) AS v
FROM $iFrom WHERE i.customer = :c AND i.document_date >= '$since24'
GROUP BY ym ORDER BY ym
", $p);
$inv['docs'] = MagoSalesStats::fetchAllQ($pdo, "
SELECT i.document_date, i.doc_no, i.document_type, i.is_credit_note, i.revenue, i.no_amount
FROM $iFrom WHERE i.customer = :c AND i.document_date >= CURDATE() - INTERVAL 3 YEAR
ORDER BY i.document_date DESC, i.doc_no DESC
", $p);
$r12 = MagoSalesStats::fetchAllQ($pdo, "
SELECT COALESCE(SUM(CASE WHEN i.is_credit_note = 0 THEN i.revenue END), 0) AS v,
COALESCE(SUM(CASE WHEN i.is_credit_note = 1 THEN -i.revenue END), 0) AS cn,
SUM(i.is_credit_note = 1) AS cn_n
FROM $iFrom WHERE i.customer = :c AND i.document_date > CURDATE() - INTERVAL 365 DAY
", $p)[0];
$inv['v12'] = (float)$r12['v'];
$inv['cn12'] = (float)$r12['cn'];
$inv['cn12_n'] = (int)$r12['cn_n'];
}
} catch (Throwable $e) {
$inv['available'] = false;
}
// --- righe da consegnare ---
$open = MagoSalesStats::fetchAllQ($pdo, "
SELECT r.sale_ord_id, r.internal_ord_no, r.order_date, 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.status, r.your_reference,
CASE WHEN r.due_date < CURDATE() THEN DATEDIFF(CURDATE(), r.due_date) ELSE 0 END AS late_days
FROM " . MagoSalesStats::openLinesFrom() . "
WHERE r.customer = :c
ORDER BY r.due_date IS NULL, r.due_date, r.internal_ord_no
", $p);
// --- articoli (righe ordine non annullate) ---
$items = MagoSalesStats::fetchAllQ($pdo, "
SELECT d.item, MAX(d.description) AS description, MAX(d.uom) AS uom,
COUNT(DISTINCT d.sale_ord_id) AS n_orders,
SUM(d.qty) AS qty, SUM(d.taxable_amount) AS value,
MAX(x.order_date) AS last_date,
SUM(CASE WHEN x.order_date > CURDATE() - INTERVAL 365 DAY THEN d.taxable_amount END) AS v12,
SUM(CASE WHEN x.order_date > CURDATE() - INTERVAL 365 DAY THEN d.qty END) AS q12,
SUM(CASE WHEN x.order_date <= CURDATE() - INTERVAL 365 DAY AND x.order_date > CURDATE() - INTERVAL 730 DAY THEN d.taxable_amount END) AS vp,
SUM(CASE WHEN x.order_date <= CURDATE() - INTERVAL 365 DAY AND x.order_date > CURDATE() - INTERVAL 730 DAY THEN d.qty END) AS qp
FROM mago_sale_ord_detail d
JOIN $from ON x.sale_ord_id = d.sale_ord_id
WHERE x.customer = :c AND x.status <> 'annullato' AND d.cancelled = 0 AND d.item <> ''
GROUP BY d.item
ORDER BY value DESC
LIMIT 500
", $p);
foreach ($items as &$it) {
$it['price12'] = (float)$it['q12'] > 0 ? round((float)$it['v12'] / (float)$it['q12'], 4) : null;
$it['pricep'] = (float)$it['qp'] > 0 ? round((float)$it['vp'] / (float)$it['qp'], 4) : null;
}
unset($it);
MagoSalesStats::jsonOut([
'success' => true,
'today' => date('Y-m-d'),
'info' => $info,
'last' => $last ? ['salesperson' => $last['salesperson'], 'area' => $last['area'], 'date' => $last['order_date'], 'no' => $last['internal_ord_no']] : null,
'first' => $firstDate,
'rhythm' => $rhythm,
'rhythm_cfg' => ['years' => MagoSalesStats::DORMANT_WINDOW_YEARS, 'min_orders' => MagoSalesStats::DORMANT_MIN_ORDERS],
'ordYears' => $ordYears,
'ordMonths' => $ordMonths,
'since' => $since24,
'inv' => $inv,
'types' => MagoSalesStats::INVOICE_TYPE_LABELS,
'orders' => array_slice($orders, 0, 300),
'open' => $open,
'items' => $items,
]);
} 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>Scheda cliente - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- DataTables + export -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap5.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/buttons/2.4.2/css/buttons.bootstrap5.min.css">
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/dataTables.buttons.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.bootstrap5.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.html5.min.js"></script>
<!-- Select2 -->
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.full.min.js"></script>
<!-- Grafici -->
<script src="https://cdn.jsdelivr.net/npm/apexcharts@3.49.1"></script>
<style>
:root {
--c-ink: #1f2d3d;
--c-muted: #64748b;
--c-line: #e2e8f0;
}
body {
font-size: 1.02rem;
background: #f8fafc;
}
.card {
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.back-dashboard {
background-color: #b9ebc7 !important;
color: var(--c-ink) !important;
border-radius: 10px;
font-weight: 600;
padding: 10px 18px;
}
/* ---------- HEADER ---------- */
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.stats-title {
display: flex;
align-items: center;
gap: 14px;
}
.stats-title-icon {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #198754, #3bb77e);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
box-shadow: 0 6px 16px rgba(25, 135, 84, .25);
}
.sync-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 12px;
border-radius: 999px;
background: #eef6ff;
color: #0b3d91;
font-size: .85rem;
font-weight: 600;
}
.sync-pill .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #94a3b8;
}
.sync-pill.ok .dot {
background: #22c55e;
}
.sync-pill.err .dot {
background: #ef4444;
}
/* ---------- TAB PAGINE ---------- */
.stats-tabs {
display: flex;
gap: 6px;
border-bottom: 2px solid var(--c-line);
margin-bottom: 18px;
flex-wrap: wrap;
}
.stats-tabs a {
padding: 10px 18px;
border-radius: 12px 12px 0 0;
font-weight: 700;
color: var(--c-muted);
text-decoration: none;
background: #f1f5f9;
margin-bottom: -2px;
border: 2px solid transparent;
}
.stats-tabs a:hover {
color: var(--c-ink);
}
.stats-tabs a.active {
background: #fff;
color: #198754;
border-color: var(--c-line);
border-bottom-color: #fff;
}
/* ---------- FILTRI ---------- */
.filters-box {
background: #fff;
border: 1px solid var(--c-line);
border-radius: 16px;
padding: 16px;
box-shadow: 0 6px 18px rgba(15, 23, 42, .05);
}
.filters-box label.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: .92rem;
cursor: pointer;
transition: all .15s ease;
display: inline-flex;
align-items: center;
gap: 7px;
}
.chip .dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--yc, #94a3b8);
}
.chip.active {
background: var(--yc, #198754);
border-color: var(--yc, #198754);
color: #fff;
}
.chip.active .dot {
background: #fff;
}
.metric-switch {
display: inline-flex;
background: #f1f5f9;
border-radius: 12px;
padding: 4px;
gap: 4px;
}
.metric-switch button {
border: 0;
background: transparent;
padding: 7px 16px;
border-radius: 9px;
font-weight: 700;
color: var(--c-muted);
}
.metric-switch button.active {
background: #fff;
color: var(--c-ink);
box-shadow: 0 2px 6px rgba(15, 23, 42, .1);
}
.ytd-switch {
display: inline-flex;
align-items: center;
gap: 10px;
background: #f1f5f9;
border-radius: 12px;
padding: 7px 14px;
font-weight: 700;
color: var(--c-ink);
cursor: pointer;
user-select: none;
}
.ytd-switch .form-check-input {
width: 2.6em;
height: 1.35em;
margin: 0;
cursor: pointer;
}
.ytd-switch .form-check-input:checked {
background-color: #198754;
border-color: #198754;
}
.select2-container--bootstrap-5 .select2-selection {
border-radius: 10px;
min-height: 42px;
}
/* ---------- CARTE ANNO ---------- */
.year-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
}
.year-card {
background: color-mix(in srgb, var(--yc) 12%, #fff);
border: 1px solid color-mix(in srgb, var(--yc) 28%, #fff);
border-radius: 16px;
padding: 16px 18px;
}
.year-card .y {
font-size: 1.05rem;
font-weight: 800;
color: color-mix(in srgb, var(--yc) 75%, #1f2d3d);
display: flex;
justify-content: space-between;
align-items: center;
}
.year-card .y small {
font-weight: 600;
font-size: .78rem;
color: var(--c-muted);
}
.year-card .big {
font-size: 1.9rem;
font-weight: 800;
color: var(--c-ink);
font-variant-numeric: tabular-nums;
line-height: 1.15;
margin-top: 6px;
}
.year-card .stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
margin-top: 10px;
}
.year-card .stats div {
background: #fff;
border-radius: 10px;
padding: 6px 8px;
}
.year-card .stats b {
display: block;
font-size: 1rem;
color: var(--c-ink);
font-variant-numeric: tabular-nums;
}
.year-card .stats span {
font-size: .72rem;
color: var(--c-muted);
font-weight: 700;
text-transform: uppercase;
}
/* ---------- CRUSCOTTO ---------- */
.dash-head {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
font-weight: 800;
color: var(--c-ink);
margin-bottom: 10px;
}
.dash-head small {
font-weight: 600;
color: var(--c-muted);
font-size: .8rem;
}
.dash-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 14px;
}
@media (min-width: 1200px) {
.dash-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.dash-card {
background: color-mix(in srgb, var(--k) 12%, #fff);
border: 1px solid color-mix(in srgb, var(--k) 28%, #fff);
border-radius: 16px;
padding: 16px 18px;
position: relative;
}
.dash-card .lbl {
font-size: .78rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .5px;
color: color-mix(in srgb, var(--k) 70%, #1f2d3d);
padding-right: 40px;
}
.dash-card .ico {
position: absolute;
right: 14px;
top: 14px;
width: 36px;
height: 36px;
border-radius: 10px;
background: #fff;
color: var(--k);
display: flex;
align-items: center;
justify-content: center;
}
.dash-card .big {
font-size: 1.75rem;
font-weight: 800;
color: var(--c-ink);
line-height: 1.15;
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.dash-card .sub {
font-size: .84rem;
color: var(--c-muted);
margin-top: 3px;
line-height: 1.4;
}
.dash-card .sub b {
color: var(--c-ink);
}
.b2b-pill {
display: inline-block;
font-size: .72rem;
font-weight: 800;
border-radius: 999px;
padding: 2px 9px;
margin-left: 6px;
vertical-align: middle;
}
.b2b-pill.up {
background: #dcfce7;
color: #166534;
}
.b2b-pill.flat {
background: #fef3c7;
color: #92400e;
}
.b2b-pill.down {
background: #fee2e2;
color: #991b1b;
}
/* ---------- CLIENTI DA RICHIAMARE ---------- */
.dorm-sum {
font-size: .9rem;
color: var(--c-muted);
}
.dorm-sum b {
color: var(--c-ink);
}
.lvl {
display: inline-block;
font-size: .72rem;
font-weight: 800;
border-radius: 999px;
padding: 2px 9px;
white-space: nowrap;
}
.lvl.watch {
background: #fef9c3;
color: #854d0e;
}
.lvl.dormant {
background: #ffedd5;
color: #9a3412;
}
.lvl.lost {
background: #fee2e2;
color: #991b1b;
}
.ratio-bar {
display: inline-block;
width: 54px;
height: 6px;
border-radius: 99px;
background: #eef2f7;
vertical-align: middle;
margin-left: 6px;
overflow: hidden;
}
.ratio-bar i {
display: block;
height: 100%;
border-radius: 99px;
}
#dormTable tbody tr {
cursor: pointer;
}
.inv-toggle {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
background: none;
border: 0;
padding: 0;
font-weight: 800;
color: var(--c-ink);
text-align: left;
}
.inv-toggle>span:first-child {
flex: 0 0 auto;
}
.inv-toggle .inv-chev {
margin-left: auto;
transition: transform .2s ease;
}
.inv-toggle[aria-expanded="true"] .inv-chev {
transform: rotate(180deg);
}
#invMonthTable tfoot th {
border-top: 2px solid var(--c-line);
font-weight: 800;
}
.doc-type {
display: inline-block;
font-size: .72rem;
font-weight: 800;
border-radius: 999px;
padding: 2px 8px;
white-space: nowrap;
}
.doc-type.t-inv {
background: #dcfce7;
color: #166534;
}
.doc-type.t-adv {
background: #e0f2fe;
color: #075985;
}
.doc-type.t-cn {
background: #fee2e2;
color: #991b1b;
}
.year-card .inv {
margin-top: 8px;
background: #fff;
border-radius: 10px;
padding: 6px 10px;
font-size: .85rem;
color: var(--c-muted);
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.year-card .inv b {
color: var(--c-ink);
font-size: 1rem;
font-variant-numeric: tabular-nums;
}
.year-card .deltas {
margin-top: 10px;
font-size: .88rem;
color: var(--c-muted);
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.delta-up {
color: #198754;
font-weight: 800;
}
.delta-down {
color: #dc3545;
font-weight: 800;
}
/* ---------- GRAFICI / TABELLA ---------- */
.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);
}
#custTable thead th {
background: #b9ebc7;
color: var(--c-ink);
white-space: nowrap;
}
#custTable tbody tr {
cursor: pointer;
}
.amount {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.trend {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 999px;
font-weight: 800;
font-size: .78rem;
white-space: nowrap;
}
.trend-new {
background: #e0f2fe;
color: #0369a1;
}
.trend-lost {
background: #fde8ea;
color: #b02a37;
}
.trend-up {
background: #dcfce7;
color: #15803d;
}
.trend-down {
background: #ffedd5;
color: #c2410c;
}
.trend-flat {
background: #f1f5f9;
color: #475569;
}
.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;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--c-muted);
}
/* ================= SCHEDA CLIENTE ================= */
.recent-chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 8px;
}
.recent-chips .chip {
font-size: .82rem;
padding: 4px 11px;
}
.profile {
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
border: 1px solid #d1fae5;
border-radius: 18px;
padding: 18px 20px;
display: flex;
gap: 18px;
align-items: flex-start;
flex-wrap: wrap;
}
.profile .avatar {
width: 60px;
height: 60px;
border-radius: 16px;
background: linear-gradient(135deg, #198754, #3bb77e);
color: #fff;
font-weight: 800;
font-size: 1.4rem;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.profile .pname {
font-size: 1.35rem;
font-weight: 800;
color: var(--c-ink);
line-height: 1.2;
}
.profile .pmeta {
color: var(--c-muted);
font-size: .9rem;
margin-top: 2px;
}
.profile .pfacts {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px;
}
.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);
}
.profile .pactions {
margin-left: auto;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.lvl {
display: inline-block;
font-size: .75rem;
font-weight: 800;
border-radius: 999px;
padding: 3px 10px;
white-space: nowrap;
vertical-align: middle;
}
.lvl.ok {
background: #dcfce7;
color: #166534;
}
.lvl.few {
background: #e2e8f0;
color: #334155;
}
.lvl.watch {
background: #fef9c3;
color: #854d0e;
}
.lvl.dormant {
background: #ffedd5;
color: #9a3412;
}
.lvl.lost {
background: #fee2e2;
color: #991b1b;
}
.dash-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
@media (min-width: 1200px) {
.dash-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.dash-card {
background: color-mix(in srgb, var(--k) 12%, #fff);
border: 1px solid color-mix(in srgb, var(--k) 28%, #fff);
border-radius: 16px;
padding: 16px 18px;
position: relative;
}
.dash-card .lbl {
font-size: .78rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .5px;
color: color-mix(in srgb, var(--k) 70%, #1f2d3d);
padding-right: 40px;
}
.dash-card .ico {
position: absolute;
right: 14px;
top: 14px;
width: 36px;
height: 36px;
border-radius: 10px;
background: #fff;
color: var(--k);
display: flex;
align-items: center;
justify-content: center;
}
.dash-card .big {
font-size: 1.7rem;
font-weight: 800;
color: var(--c-ink);
line-height: 1.15;
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.dash-card .sub {
font-size: .84rem;
color: var(--c-muted);
margin-top: 3px;
line-height: 1.45;
}
.dash-card .sub b {
color: var(--c-ink);
}
.st-badge {
display: inline-block;
font-size: .72rem;
font-weight: 800;
border-radius: 999px;
padding: 2px 9px;
white-space: nowrap;
}
.st-aperto {
background: #dbeafe;
color: #1e40af;
}
.st-evaso {
background: #dcfce7;
color: #166534;
}
.st-annullato {
background: #e2e8f0;
color: #475569;
}
.st-bloccato {
background: #fde68a;
color: #92400e;
}
.late-badge {
display: inline-block;
font-size: .72rem;
font-weight: 800;
border-radius: 999px;
padding: 2px 9px;
background: #fee2e2;
color: #b91c1c;
margin-left: 4px;
white-space: nowrap;
}
.week-badge {
display: inline-block;
background: #fef3c7;
color: #b45309;
font-size: .66rem;
font-weight: 800;
border-radius: 999px;
padding: 1px 7px;
margin-left: 5px;
white-space: nowrap;
}
.dt-zibo,
.dt-client {
white-space: nowrap;
font-size: .88rem;
}
.dt-zibo.late {
color: #dc2626;
font-weight: 800;
}
.dt-client {
color: #64748b;
}
.dt-client.late {
color: #ea580c;
font-weight: 700;
}
.dt-tag {
display: inline-block;
min-width: 62px;
font-size: .68rem;
font-weight: 800;
text-transform: uppercase;
color: #94a3b8;
}
.doc-type {
display: inline-block;
font-size: .72rem;
font-weight: 800;
border-radius: 999px;
padding: 2px 8px;
white-space: nowrap;
}
.doc-type.t-inv {
background: #dcfce7;
color: #166534;
}
.doc-type.t-adv {
background: #e0f2fe;
color: #075985;
}
.doc-type.t-cn {
background: #fee2e2;
color: #991b1b;
}
.sc-tabs {
display: flex;
gap: 4px;
flex-wrap: wrap;
background: #f1f5f9;
border-radius: 12px;
padding: 4px;
margin-bottom: 14px;
}
.sc-tabs button {
border: 0;
background: transparent;
padding: 8px 16px;
border-radius: 9px;
font-weight: 700;
color: var(--c-muted);
}
.sc-tabs button.active {
background: #fff;
color: var(--c-ink);
box-shadow: 0 2px 6px rgba(15, 23, 42, .1);
}
.sc-tabs .badge {
margin-left: 4px;
}
.sc-table thead th {
background: #b9ebc7;
color: var(--c-ink);
white-space: nowrap;
}
.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">
<!-- HEADER -->
<div class="card-header bg-white border-0 pb-3">
<div class="stats-header">
<div class="stats-title">
<div class="stats-title-icon"><i class="fa-solid fa-address-card"></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">
<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" class="active"><i class="fa-solid fa-address-card me-1"></i> Scheda cliente</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.customer"><i class="fa-regular fa-circle-question me-1"></i> Come si legge</button>
</div>
<!-- SCELTA CLIENTE -->
<div class="filters-box mb-4">
<label class="flabel" for="fCustomer">Cliente</label>
<select id="fCustomer" style="width:100%;"></select>
<div class="recent-chips" id="recentChips"></div>
</div>
<div id="emptyState" class="empty-state">
<i class="fa-solid fa-address-card fa-3x mb-3" style="color:#cbd5e1;"></i>
<div class="fw-bold">Scegli un cliente</div>
<div class="small">Trovi tutto su di lui in una pagina: ordinato e fatturato, ordini aperti, articoli, fatture.</div>
</div>
<div id="content" style="display:none;">
<!-- PROFILO -->
<div class="profile mb-3" id="profile"></div>
<!-- KPI -->
<div class="dash-grid mb-3" id="kpis"></div>
<div class="d-flex justify-content-end mb-2"><button type="button" data-layout-btn="sales_customer"></button></div>
<!-- RIQUADRI (ordine personalizzabile per utente: ui_layout.js) -->
<div class="row g-3 mb-3" <?= UiPrefs::attr($pdo, $iduserlogin ?? 0, 'sales_customer') ?>>
<div class="col-xl-5" data-widget="years" data-title="Ordinato e fatturato per anno">
<div class="chart-card">
<div class="chart-title" data-help="sc.years">Ordinato e fatturato per anno</div>
<div class="chart-hint" id="yearsHint">Anno in corso: valori ad oggi</div>
<div id="chYears" style="min-height:300px;"></div>
</div>
</div>
<div class="col-xl-7" data-widget="months" data-title="Ultimi 24 mesi">
<div class="chart-card">
<div class="chart-title" data-help="sc.months">Ultimi 24 mesi</div>
<div class="chart-hint">Ordinato per data ordine, fatturato per data fattura</div>
<div id="chMonths" style="min-height:300px;"></div>
</div>
</div>
<div class="col-12" data-widget="tables" data-title="Ordini, articoli e fatture">
<div class="chart-card">
<div class="sc-tabs" id="scTabs">
<button type="button" data-tab="open" class="active"><i class="fa-solid fa-truck-ramp-box me-1"></i> Da consegnare <span class="badge bg-light text-dark" id="nOpen">0</span></button>
<button type="button" data-tab="orders"><i class="fa-solid fa-file-invoice me-1"></i> Ordini <span class="badge bg-light text-dark" id="nOrders">0</span></button>
<button type="button" data-tab="items"><i class="fa-solid fa-cubes me-1"></i> Articoli <span class="badge bg-light text-dark" id="nItems">0</span></button>
<button type="button" data-tab="docs"><i class="fa-solid fa-receipt me-1"></i> Fatture <span class="badge bg-light text-dark" id="nDocs">0</span></button>
<span class="ms-auto align-self-center pe-2" data-help="sc.tables"></span>
</div>
<div class="sc-pane" data-pane="open">
<div class="table-responsive">
<table id="tOpen" class="table table-striped align-middle w-100 sc-table"></table>
</div>
</div>
<div class="sc-pane" data-pane="orders" style="display:none;">
<div class="table-responsive">
<table id="tOrders" class="table table-striped align-middle w-100 sc-table"></table>
</div>
</div>
<div class="sc-pane" data-pane="items" style="display:none;">
<div class="chart-hint mb-2">Prezzo medio = imponibile ÷ quantità. La variazione confronta gli ultimi 12 mesi con i 12 precedenti.</div>
<div class="table-responsive">
<table id="tItems" class="table table-striped align-middle w-100 sc-table"></table>
</div>
</div>
<div class="sc-pane" data-pane="docs" style="display:none;">
<div class="table-responsive">
<table id="tDocs" class="table table-striped align-middle w-100 sc-table"></table>
</div>
</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 (come dopo il click sulla freccia)
$(function() {
$('.sidebar-wrapper').hover(
function() {
$('.wrapper.toggled').addClass('sidebar-hovered');
},
function() {
$('.wrapper').removeClass('sidebar-hovered');
}
);
});
</script>
<script>
(function() {
const MONTHS = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'];
const STORE_KEY = 'zibo_sales_customer_v1';
const CUR_Y = new Date().getFullYear();
const ZIBO_WEEK = <?= MagoSalesStats::ZIBO_IS_WEEK ? 'true' : 'false' ?>;
const ZIBO_END_DAYS = <?= (int)MagoSalesStats::ZIBO_WEEK_END_DAYS ?>;
const ST_LABEL = {
aperto: 'Aperto',
evaso: 'Evaso',
annullato: 'Annullato',
bloccato: 'Bloccato'
};
const LVL = {
ok: 'Cliente attivo',
few: 'Ordini occasionali',
watch: 'Attenzione',
dormant: 'Dormiente',
lost: 'Probabilmente perso'
};
const charts = {};
const tables = {};
let customers = [];
let current = '';
let lastData = null;
let tab = 'open';
let reqSeq = 0;
/* ---------------- UTILS ---------------- */
function esc(s) {
return 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
});
function fmtEurShort(n) {
n = +n || 0;
const a = Math.abs(n);
if (a >= 1e6) return (n / 1e6).toLocaleString('it-IT', {
maximumFractionDigits: 2
}) + ' M€';
if (a >= 1e3) return (n / 1e3).toLocaleString('it-IT', {
maximumFractionDigits: 1
}) + ' k€';
return 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 today = iso(new Date());
function pct(cur, prev) {
return prev ? (cur - prev) / Math.abs(prev) * 100 : null;
}
function deltaSpan(p, label) {
if (p === null || !isFinite(p)) return '';
const r = Math.round(p);
return `<span class="${r >= 0 ? 'delta-up' : 'delta-down'}">${r >= 0 ? '▲' : '▼'} ${Math.abs(r)}%</span> ${label || ''}`;
}
// data ZIBO = settimana: scade a fine settimana
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);
}
function dateCell(z, c) {
let h = '';
if (z) {
const late = ziboEnd(z) < today;
const week = !late && ZIBO_WEEK && String(z).substring(0, 10) <= today;
h += `<div class="dt-zibo${late ? ' late' : ''}" title="${ZIBO_WEEK ? 'Settimana del ' + fmtDate(z) + ': consegna entro ' + fmtDate(ziboEnd(z)) : ''}"><span class="dt-tag">ZIBO${ZIBO_WEEK ? ' sett.' : ''}</span>${fmtDate(z)}${week ? '<span class="week-badge">IN SETTIMANA</span>' : ''}</div>`;
}
if (c) h += `<div class="dt-client${c < today ? ' late' : ''}"><span class="dt-tag">Cliente</span>${fmtDate(c)}</div>`;
return h || '<span class="text-muted">—</span>';
}
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'
}
}]
});
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>>";
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
}, opts));
}
function orderLink(no) {
return `<a class="ord-link" href="sales_orders_stats.php?q=${encodeURIComponent(no)}" title="Apri l'ordine">${esc(no)}</a>`;
}
/* ---------------- STATO ---------------- */
function store() {
try {
return JSON.parse(localStorage.getItem(STORE_KEY) || '{}') || {};
} catch (e) {
return {};
}
}
function remember(code) {
try {
const s = store();
s.recent = [code].concat((s.recent || []).filter(c => c !== code)).slice(0, 6);
s.tab = tab;
localStorage.setItem(STORE_KEY, JSON.stringify(s));
} catch (e) {}
renderRecent();
}
function renderRecent() {
const rec = (store().recent || []).filter(c => customers.some(x => x.code === c));
$('#recentChips').html(rec.length ? '<span class="small text-muted align-self-center me-1">Visti di recente:</span>' + rec.map(c => {
const x = customers.find(k => k.code === c);
return `<button type="button" class="chip${c === current ? ' active' : ''}" data-c="${esc(c)}">${esc(x ? x.name : c)}</button>`;
}).join('') : '');
}
/* ---------------- CARICAMENTO ---------------- */
function loadFilters() {
return fetch('?action=filters').then(r => r.json()).then(d => {
customers = d.customers || [];
$('#fCustomer').empty().append('<option></option>');
customers.forEach(c => $('#fCustomer').append(new Option(`${c.name} (${c.code})`, c.code)));
const s = d.sync || {};
const so = s.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} · ${fmtNum(s.total_orders)} ordini in archivio` : 'Dati Mago4');
});
}
function load(code) {
if (!code) return;
current = code;
if ($('#fCustomer').val() !== code) $('#fCustomer').val(code).trigger('change.select2');
const u = new URL(location.href);
u.searchParams.set('c', code);
history.replaceState(null, '', u.toString());
const seq = ++reqSeq;
$('#content').css('opacity', .55);
fetch('?action=data&c=' + encodeURIComponent(code)).then(r => r.json()).then(d => {
if (seq !== reqSeq) return;
$('#content').css('opacity', 1);
if (!d.success) {
$('#content').hide();
$('#emptyState').show().find('.fw-bold').text(d.message || 'Errore nel caricamento');
return;
}
lastData = d;
$('#emptyState').hide();
$('#content').show();
remember(code);
renderProfile(d);
renderKpis(d);
renderYears(d);
renderMonths(d);
renderTables(d);
}).catch(e => {
$('#content').css('opacity', 1);
console.error(e);
});
}
/* ---------------- PROFILO ---------------- */
function renderProfile(d) {
const i = d.info,
r = d.rhythm,
l = d.last;
const initials = String(i.name || i.code).replace(/[^A-Za-zÀ-ú0-9 ]/g, '').split(' ').filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase();
const place = [i.city, i.county ? `(${i.county})` : '', i.country && i.country.toUpperCase() !== 'ITALIA' ? i.country : ''].filter(Boolean).join(' ');
const lvl = r ? r.level : 'few';
const facts = [];
if (d.first) facts.push(`<div class="fact">Cliente dal <b>${fmtDate(d.first)}</b></div>`);
if (l) facts.push(`<div class="fact">Ultimo ordine <b>${fmtDate(l.date)}</b> · ${fmtNum(r ? r.days_since : 0)} gg fa · ${orderLink(l.no)}</div>`);
if (r && r.rhythm) facts.push(`<div class="fact">Ordina ogni <b>~${fmtNum(r.rhythm)} gg</b>${r.ratio !== null && r.level !== 'ok' ? ` · ritardo <b>${String(r.ratio).replace('.', ',')}×</b>` : ''}</div>`);
if (r) facts.push(`<div class="fact"><b>${fmtNum(r.orders)}</b> giorni d'ordine negli ultimi ${d.rhythm_cfg.years} anni</div>`);
if (l && l.salesperson) facts.push(`<div class="fact">Agente <b>${esc(l.salesperson)}</b></div>`);
if (l && l.area) facts.push(`<div class="fact">Area <b>${esc(l.area)}</b></div>`);
$('#profile').html(`
<div class="avatar">${esc(initials || '?')}</div>
<div style="min-width:240px;flex:1;">
<div class="pname">${esc(i.name)} <span class="lvl ${lvl}" title="${lvl === 'few' ? 'Meno di ' + d.rhythm_cfg.min_orders + ' ordini negli ultimi ' + d.rhythm_cfg.years + ' anni: ritmo non stimabile' : 'In base al suo ritmo d\'ordine abituale'}">${LVL[lvl]}</span><span data-help="sc.status"></span></div>
<div class="pmeta">${esc(i.code)}${place ? ' · ' + esc(place) : ''}</div>
<div class="pfacts">${facts.join('')}</div>
</div>
<div class="pactions">
<a class="btn btn-outline-primary btn-sm" href="sales_orders_stats.php?q=${encodeURIComponent(i.code)}"><i class="fa-solid fa-list-check me-1"></i> Ordini in Andamento</a>
</div>`);
}
/* ---------------- KPI ---------------- */
function yearRow(rows, y) {
return (rows || []).find(r => +r.y === y) || null;
}
function renderKpis(d) {
const out = [];
const invOk = d.inv && d.inv.available;
const label = new Date().toLocaleDateString('it-IT', {
day: 'numeric',
month: 'short'
});
const ytdCard = (title, icon, color, rows, help) => {
const c = yearRow(rows, CUR_Y),
p = yearRow(rows, CUR_Y - 1);
const v = c ? +c.ytd : 0,
pv = p ? +p.ytd : 0;
return `<div class="dash-card" style="--k:${color};">
<div class="ico"><i class="fa-solid ${icon}"></i></div>
<div class="lbl" data-help="${help}">${title} ${CUR_Y}</div>
<div class="big">${fmtEurShort(v)}</div>
<div class="sub">al ${label} · ${CUR_Y - 1} stessa data: <b>${fmtEurShort(pv)}</b> ${deltaSpan(pct(v, pv), '')}<br>${CUR_Y - 1} intero: <b>${fmtEurShort(p ? +p.full : 0)}</b></div>
</div>`;
};
out.push(invOk ? ytdCard('Fatturato', 'fa-file-invoice-dollar', '#0f766e', d.inv.years, 'sc.inv') :
`<div class="dash-card" style="--k:#64748b;"><div class="ico"><i class="fa-solid fa-file-invoice-dollar"></i></div><div class="lbl" data-help="sc.inv">Fatturato</div><div class="big">—</div><div class="sub">serve la sincronizzazione delle fatture</div></div>`);
out.push(ytdCard('Ordinato', 'fa-chart-line', '#6f42c1', d.ordYears, 'sc.ord'));
const open = d.open || [];
const ov = open.reduce((a, r) => a + (+r.residual_value || 0), 0);
const late = open.filter(r => r.due_date && r.due_date < today);
const lv = late.reduce((a, r) => a + (+r.residual_value || 0), 0);
out.push(`<div class="dash-card" style="--k:#1d4ed8;">
<div class="ico"><i class="fa-solid fa-truck-ramp-box"></i></div>
<div class="lbl" data-help="sc.open">Da consegnare</div>
<div class="big">${fmtEurShort(ov)}</div>
<div class="sub">${fmtNum(open.length)} righe · ${fmtNum(new Set(open.map(r => r.sale_ord_id)).size)} ordini${late.length ? `<br><span class="delta-down">${fmtNum(late.length)} righe in ritardo</span> · ${fmtEurShort(lv)}` : '<br>nessuna riga in ritardo'}</div>
</div>`);
if (invOk) {
const p = d.inv.v12 > 0 ? d.inv.cn12 / d.inv.v12 * 100 : 0;
out.push(`<div class="dash-card" style="--k:${p >= 3 ? '#b02a37' : '#64748b'};">
<div class="ico"><i class="fa-solid fa-rotate-left"></i></div>
<div class="lbl" data-help="sc.cn">Note di credito · 12 mesi</div>
<div class="big">${fmtEurShort(d.inv.cn12)}</div>
<div class="sub">${fmtNum(d.inv.cn12_n)} note · <b>${p.toLocaleString('it-IT', {maximumFractionDigits: 1})}%</b> del fatturato<br>fatturato 12 mesi: <b>${fmtEurShort(d.inv.v12)}</b></div>
</div>`);
} else {
out.push(`<div class="dash-card" style="--k:#64748b;"><div class="ico"><i class="fa-solid fa-rotate-left"></i></div><div class="lbl" data-help="sc.cn">Note di credito</div><div class="big">—</div><div class="sub">serve la sincronizzazione delle fatture</div></div>`);
}
$('#kpis').html(out.join(''));
}
/* ---------------- GRAFICI ---------------- */
const base = {
fontFamily: 'inherit',
toolbar: {
show: false
},
animations: {
speed: 350
}
};
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();
}
}
function barOpts(cats, series, h) {
return {
chart: {
...base,
type: 'bar',
height: h || 300
},
series: series,
colors: ['#6f42c1', '#0f766e'],
plotOptions: {
bar: {
columnWidth: '62%',
borderRadius: 3
}
},
dataLabels: {
enabled: false
},
xaxis: {
categories: cats,
labels: {
rotate: -45,
hideOverlappingLabels: true
}
},
yaxis: {
labels: {
formatter: fmtEurShort
}
},
tooltip: {
shared: true,
intersect: false,
y: {
formatter: v => fmtEur(v)
}
},
legend: {
position: 'top',
horizontalAlign: 'left'
},
grid: {
borderColor: '#eef2f7'
}
};
}
function renderYears(d) {
const invOk = d.inv && d.inv.available;
const ys = new Set((d.ordYears || []).map(r => +r.y));
if (invOk)(d.inv.years || []).forEach(r => ys.add(+r.y));
const years = [...ys].sort();
const val = (rows, y) => {
const r = yearRow(rows, y);
return r ? Math.round(+r.full) : 0;
};
const series = [{
name: 'Ordinato',
data: years.map(y => val(d.ordYears, y))
}];
if (invOk) series.push({
name: 'Fatturato',
data: years.map(y => val(d.inv.years, y))
});
upsert('years', 'chYears', barOpts(years.map(y => y === CUR_Y ? y + ' (ad oggi)' : String(y)), series));
}
function renderMonths(d) {
const keys = [];
const start = new Date(d.since + 'T00:00:00');
for (let i = 0; i < 24; i++) {
const dd = new Date(start.getFullYear(), start.getMonth() + i, 1);
keys.push(dd.getFullYear() + '-' + String(dd.getMonth() + 1).padStart(2, '0'));
}
const toMap = rows => {
const m = {};
(rows || []).forEach(r => m[r.ym] = Math.round(+r.v));
return m;
};
const om = toMap(d.ordMonths);
const series = [{
name: 'Ordinato',
data: keys.map(k => om[k] || 0)
}];
if (d.inv && d.inv.available) {
const im = toMap(d.inv.months);
series.push({
name: 'Fatturato',
data: keys.map(k => im[k] || 0)
});
}
upsert('months', 'chMonths', barOpts(keys.map(k => MONTHS[+k.substring(5) - 1] + ' ' + k.substring(2, 4)), series));
}
/* ---------------- TABELLE ---------------- */
function renderTables(d) {
const name = d.info.name;
$('#nOpen').text(fmtNum((d.open || []).length));
$('#nOrders').text(fmtNum((d.orders || []).length));
$('#nItems').text(fmtNum((d.items || []).length));
$('#nDocs').text(d.inv && d.inv.available ? fmtNum((d.inv.docs || []).length) : '—');
// DA CONSEGNARE
makeTable('open', '#tOpen', {
data: d.open || [],
columns: [{
title: 'Ordine',
data: 'internal_ord_no',
render: (v, t, r) => t === 'display' ? `${orderLink(v)}<div class="small text-muted">${fmtDate(r.order_date)}${r.your_reference ? ' · ' + esc(r.your_reference) : ''}</div>` : v
}, {
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)}</div>` : (t === 'export' ? `${v} - ${r.description}` : `${v} ${r.description}`)
}, {
title: 'Da consegnare',
data: 'residual_qty',
className: 'text-end text-nowrap',
render: (v, t, r) => t === 'display' ? `<b>${fmtNum(+v, +v % 1 ? 2 : 0)}</b> ${esc(r.uom)}<div class="small text-muted">su ${fmtNum(+r.qty, +r.qty % 1 ? 2 : 0)}</div>` : +v
}, {
title: 'Valore',
data: 'residual_value',
className: 'text-end amount',
render: (v, t) => t === 'display' ? fmtEur(+v, 2) : +v
}, {
title: 'Consegna',
data: 'due_date',
render: (v, t, r) => t === 'display' ? dateCell(r.zibo_date, r.client_date) : (t === 'export' ? `ZIBO ${fmtDate(r.zibo_date)} / cliente ${fmtDate(r.client_date)}` : (v || '9999'))
}, {
title: 'Ritardo',
data: 'late_days',
className: 'text-end',
render: (v, t) => t === 'display' ? (+v > 0 ? `<span class="late-badge">+${fmtNum(v)} gg</span>` : '<span class="text-muted">—</span>') : +v
}],
order: [
[4, 'asc']
],
buttons: xls(`Da consegnare - ${name}`)
});
// ORDINI
makeTable('orders', '#tOrders', {
data: d.orders || [],
columns: [{
title: 'Ordine',
data: 'internal_ord_no',
render: (v, t) => t === 'display' ? orderLink(v) : v
}, {
title: 'Data',
data: 'order_date',
render: (v, t) => t === 'display' ? fmtDate(v) : v
}, {
title: 'Rif. cliente',
data: 'your_reference',
render: (v, t, r) => t === 'display' ? esc(v) + (r.external_ord_no ? `<div class="small text-muted">${esc(r.external_ord_no)}</div>` : '') : v
}, {
title: 'Agente',
data: 'salesperson'
}, {
title: 'Valore',
data: 'taxable_amount',
className: 'text-end amount',
render: (v, t, r) => t === 'display' ? (+r.no_amount ? '<span class="text-muted">—</span>' : fmtEur(+v, 2)) : +v
}, {
title: 'Stato',
data: 'status',
render: (v, t, r) => t === 'display' ?
`<span class="st-badge st-${esc(v)}">${ST_LABEL[v] || esc(v)}</span>${+r.is_late ? '<span class="late-badge">IN RITARDO</span>' : ''}${+r.invoiced ? ' <i class="fa-solid fa-receipt text-success" title="Fatturato"></i>' : ''}` : (ST_LABEL[v] || v) + (+r.is_late ? ' (in ritardo)' : '')
}],
order: [
[1, 'desc']
],
buttons: xls(`Ordini - ${name}`)
});
// ARTICOLI
makeTable('items', '#tItems', {
data: d.items || [],
columns: [{
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)}</div>` : (t === 'export' ? v : `${v} ${r.description}`)
}, {
title: 'Descrizione',
data: 'description',
visible: false
}, {
title: 'Ordini',
data: 'n_orders',
className: 'text-end'
}, {
title: 'Quantità',
data: 'qty',
className: 'text-end text-nowrap',
render: (v, t, r) => t === 'display' ? `${fmtNum(+v, +v % 1 ? 2 : 0)} ${esc(r.uom)}` : +v
}, {
title: 'Valore',
data: 'value',
className: 'text-end amount',
render: (v, t) => t === 'display' ? fmtEur(+v) : +v
}, {
title: 'Prezzo medio 12 mesi',
data: 'price12',
className: 'text-end amount',
render: (v, t, r) => t === 'display' ? (v === null ? '<span class="text-muted">—</span>' : `${fmtEur(v, 3)} <span class="small text-muted">/${esc(r.uom)}</span>`) : (v === null ? '' : +v)
}, {
title: 'Var. prezzo',
data: null,
className: 'text-end',
render: (r, t) => {
const p = r.price12 !== null && r.pricep ? (r.price12 - r.pricep) / r.pricep * 100 : null;
if (t === 'export') return p === null ? '' : Math.round(p * 10) / 1000;
if (t !== 'display') return p === null ? -1e9 : p;
if (p === null) return '<span class="text-muted">—</span>';
const x = Math.round(p * 10) / 10;
return `<span class="${x >= 0 ? 'delta-up' : 'delta-down'}" title="12 mesi prima: ${fmtEur(r.pricep, 3)}">${x >= 0 ? '+' : ''}${x.toLocaleString('it-IT')}%</span>`;
}
}, {
title: 'Ultimo ordine',
data: 'last_date',
render: (v, t) => t === 'display' ? fmtDate(v) : v
}],
order: [
[4, 'desc']
],
buttons: xls(`Articoli - ${name}`)
});
// FATTURE
const types = d.types || {};
makeTable('docs', '#tDocs', {
data: (d.inv && d.inv.docs) || [],
columns: [{
title: 'Data',
data: 'document_date',
render: (v, t) => t === 'display' ? fmtDate(v) : v
}, {
title: 'N° documento',
data: 'doc_no'
}, {
title: 'Tipo',
data: 'document_type',
render: (v, t, r) => {
const l = types[v] || v;
const cls = +r.is_credit_note ? 't-cn' : (+v === 3407874 ? 't-inv' : 't-adv');
return t === 'display' ? `<span class="doc-type ${cls}">${esc(l)}</span>` : l;
}
}, {
title: 'Imponibile',
data: 'revenue',
className: 'text-end amount',
render: (v, t, r) => t === 'display' ? (+r.no_amount ? '<span class="text-muted">—</span>' : `<span class="${+v < 0 ? 'delta-down' : ''}">${fmtEur(+v, 2)}</span>`) : +v
}],
order: [
[0, 'desc']
],
language: Object.assign({}, DT_LANG, {
emptyTable: d.inv && d.inv.available ? 'Nessuna fattura negli ultimi 3 anni' : 'Fatture non ancora sincronizzate'
}),
buttons: xls(`Fatture - ${name}`)
});
showTab(tab);
}
function showTab(t) {
tab = t;
$('#scTabs button').removeClass('active');
$(`#scTabs button[data-tab="${t}"]`).addClass('active');
$('.sc-pane').hide();
$(`.sc-pane[data-pane="${t}"]`).show();
// ricalcolo larghezze colonne della tabella appena mostrata
try {
$.fn.dataTable.tables({
visible: true,
api: true
}).columns.adjust();
} catch (e) {}
}
/* ---------------- EVENTI ---------------- */
$(function() {
$('#fCustomer').select2({
theme: 'bootstrap-5',
placeholder: 'Cerca un cliente per nome o codice…',
allowClear: true,
width: '100%'
}).on('select2:select', e => load(e.params.data.id));
$('#recentChips').on('click', '.chip', function() {
load(String($(this).data('c')));
});
$('#scTabs').on('click', 'button', function() {
showTab($(this).data('tab'));
try {
const s = store();
s.tab = tab;
localStorage.setItem(STORE_KEY, JSON.stringify(s));
} catch (e) {}
});
const st = store();
if (['open', 'orders', 'items', 'docs'].includes(st.tab)) tab = st.tab;
loadFilters().then(() => {
renderRecent();
const q = new URLSearchParams(location.search).get('c');
const first = q || (st.recent || [])[0];
if (first) load(first);
});
});
})();
</script>
<script src="sales_help.js?v=3"></script>
<script src="ui_layout.js?v=1"></script>
</body>
</html>