1931 lines
71 KiB
PHP
1931 lines
71 KiB
PHP
<?php
|
||
ob_start();
|
||
ini_set('display_errors', 1);
|
||
error_reporting(E_ALL);
|
||
|
||
include('include/headscript.php');
|
||
require_once __DIR__ . '/class/MagoSalesStats.php';
|
||
|
||
$db = DBHandlerSelect::getInstance();
|
||
$pdo = $db->getConnection();
|
||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||
|
||
// --- Permessi: pagina visibile solo con 'sales.stats.view' ---
|
||
if (!userCan('sales.stats.view')) {
|
||
if (isset($_GET['action']) || isset($_POST['action'])) {
|
||
MagoSalesStats::jsonOut(['success' => false, 'ok' => false, 'message' => 'Non hai i permessi per questa pagina']);
|
||
}
|
||
while (ob_get_level()) ob_end_clean();
|
||
http_response_code(403);
|
||
echo '<div style="font-family:sans-serif;padding:60px;text-align:center;"><h3>Accesso non consentito</h3><p>Non hai i permessi per visualizzare questa pagina.</p><a href="production_dashboard.php">Torna alla Dashboard</a></div>';
|
||
exit;
|
||
}
|
||
$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_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>
|
||
|
||
<!-- GRAFICI -->
|
||
<div class="row g-3 mb-3">
|
||
<div class="col-xl-5">
|
||
<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">
|
||
<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>
|
||
|
||
<!-- TABELLE -->
|
||
<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>
|
||
|
||
<?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 => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
} [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=1"></script>
|
||
</body>
|
||
|
||
</html>
|