From c3234e3160419a7da6a1bad15a3d58e667bbcb32 Mon Sep 17 00:00:00 2001 From: solocla Date: Thu, 15 Oct 2026 10:06:58 +0200 Subject: [PATCH] added book to bill --- public/userarea/class/MagoSalesStats.php | 118 +++++ public/userarea/sales_orders_years.php | 587 ++++++++++++++++++++++- 2 files changed, 701 insertions(+), 4 deletions(-) diff --git a/public/userarea/class/MagoSalesStats.php b/public/userarea/class/MagoSalesStats.php index 6a68b280..b1a047ea 100644 --- a/public/userarea/class/MagoSalesStats.php +++ b/public/userarea/class/MagoSalesStats.php @@ -70,6 +70,124 @@ class MagoSalesStats 3407876 => -1, // Nota di credito (TD04) → in negativo serie D ... ]; + /** + * CLIENTI DA RICHIAMARE: clienti "regolari" (almeno DORMANT_MIN_ORDERS giorni d'ordine negli ultimi + * DORMANT_WINDOW_YEARS anni) in ritardo rispetto al loro ritmo abituale (mediana dei giorni tra due ordini). + * ritardo = giorni dall'ultimo ordine / ritmo abituale + */ + const DORMANT_WINDOW_YEARS = 3; + const DORMANT_MIN_ORDERS = 4; + const DORMANT_MIN_DAYS = 30; // sotto i 30 giorni dall'ultimo ordine nessun allarme + const DORMANT_WATCH = 1.5; // da 1,5 volte il ritmo: attenzione + const DORMANT_SLEEP = 2.0; // da 2 volte: dormiente + const DORMANT_LOST = 4.0; // da 4 volte (o oltre 1 anno): probabilmente perso + + /** + * Calcola la lista clienti da richiamare a partire dagli ordini (code, name, order_date, v, salesperson, area, ord_no). + */ + public static function dormantCustomers(array $rows) + { + $by = []; + foreach ($rows as $r) { + $c = $r['code']; + if (!isset($by[$c])) $by[$c] = ['code' => $c, 'name' => $r['name'], 'dates' => [], 'orders' => []]; + $by[$c]['dates'][$r['order_date']] = true; + $by[$c]['orders'][] = $r; + } + + $today = new DateTime('today'); + $out = []; + foreach ($by as $c) { + $dates = array_keys($c['dates']); + sort($dates); + $n = count($dates); + if ($n < self::DORMANT_MIN_ORDERS) continue; + + $gaps = []; + for ($k = 1; $k < $n; $k++) { + $gaps[] = (int)(new DateTime($dates[$k - 1]))->diff(new DateTime($dates[$k]))->days; + } + sort($gaps); + $m = count($gaps); + $median = $m % 2 ? $gaps[intdiv($m, 2)] : ($gaps[$m / 2 - 1] + $gaps[$m / 2]) / 2; + $rhythm = max(7, (float)$median); + + $last = end($dates); + $since = (int)(new DateTime($last))->diff($today)->days; + if ($since < self::DORMANT_MIN_DAYS) continue; + + $ratio = $since / $rhythm; + if ($ratio >= self::DORMANT_LOST || $since >= 365) $level = 'lost'; + elseif ($ratio >= self::DORMANT_SLEEP) $level = 'dormant'; + elseif ($ratio >= self::DORMANT_WATCH) $level = 'watch'; + else continue; + + // ordinato nei 12 mesi fino all'ultimo ordine = quanto vale il cliente "a regime" + $from12 = (new DateTime($last))->modify('-365 days')->format('Y-m-d'); + $v12 = 0.0; + $lastOrder = null; + foreach ($c['orders'] as $o) { + if ($o['order_date'] > $from12) $v12 += (float)$o['v']; + if ( + !$lastOrder || $o['order_date'] > $lastOrder['order_date'] || + ($o['order_date'] === $lastOrder['order_date'] && $o['ord_no'] > $lastOrder['ord_no']) + ) $lastOrder = $o; + } + + $out[] = [ + 'code' => $c['code'], + 'name' => $c['name'], + 'salesperson' => $lastOrder['salesperson'], + 'area' => $lastOrder['area'], + 'last_date' => $last, + 'last_no' => $lastOrder['ord_no'], + 'days_since' => $since, + 'rhythm' => (int)round($rhythm), + 'ratio' => round($ratio, 1), + 'orders' => $n, + 'value12' => round($v12, 2), + 'level' => $level, + ]; + } + usort($out, function ($a, $b) { + return $b['value12'] <=> $a['value12']; + }); + return $out; + } + + /** + * Previsione di fine anno: valore dell'anno in corso ad oggi diviso la quota che, negli anni + * precedenti (max 3), si era raggiunta alla stessa data. $rows: [y, full, ytd]. + */ + public static function yearForecast(array $rows, $curYear) + { + $cur = null; + $shares = []; + $prevFull = null; + foreach ($rows as $r) { + $y = (int)$r['y']; + if ($y === $curYear) $cur = (float)$r['ytd']; + elseif ($y >= $curYear - 3 && $y < $curYear && (float)$r['full'] > 0 && (float)$r['ytd'] > 0) { + $shares[$y] = (float)$r['ytd'] / (float)$r['full']; + } + if ($y === $curYear - 1) $prevFull = (float)$r['full']; + } + if ($cur === null || !$shares) { + return ['ytd' => $cur, 'forecast' => null, 'prev_full' => $prevFull]; + } + $avg = array_sum($shares) / count($shares); + ksort($shares); + return [ + 'ytd' => round($cur, 2), + 'forecast' => round($cur / $avg, 2), + 'low' => round($cur / max($shares), 2), + 'high' => round($cur / min($shares), 2), + 'prev_full' => $prevFull, + 'years' => array_keys($shares), + 'share' => round($avg * 100, 1), + ]; + } + /** Etichette dei tipi documento (tabella documenti). */ const INVOICE_TYPE_LABELS = [ 3407874 => 'Fattura', diff --git a/public/userarea/sales_orders_years.php b/public/userarea/sales_orders_years.php index 3bd6f072..0581f6b0 100644 --- a/public/userarea/sales_orders_years.php +++ b/public/userarea/sales_orders_years.php @@ -148,9 +148,9 @@ if ($action === 'data') { // --- FATTURATO per data documento (fatture + acconti - note di credito) --- $invoiced = ['available' => false, 'summary' => [], 'monthly' => []]; + $iFrom = MagoSalesStats::invoiceFrom(); + $iWhere = preg_replace('/\bx\./', 'i.', $where); // stessi filtri cliente/agente/area try { - $iFrom = MagoSalesStats::invoiceFrom(); - $iWhere = preg_replace('/\bx\./', 'i.', $where); // stessi filtri cliente/agente/area $iYtd = $ytd ? " AND DATE_FORMAT(i.document_date, '%m-%d') <= DATE_FORMAT(CURDATE(), '%m-%d')" : ''; $invoiced['summary'] = MagoSalesStats::fetchAllQ($pdo, " @@ -178,8 +178,84 @@ if ($action === 'data') { $invoiced['error'] = 'Fatturato non disponibile: eseguire la migrazione e la sincronizzazione'; } + // ===== CRUSCOTTO DIREZIONE: situazione ad oggi (rispetta solo i filtri cliente / agente / area) ===== + $curY = (int)date('Y'); + $val = "CASE WHEN x.status <> 'annullato' THEN x.taxable_amount ELSE 0 END"; + $dash = []; + + // portafoglio da consegnare = valore residuo delle righe aperte + $rWhere = preg_replace('/\bx\./', 'r.', $where); + $dash['backlog'] = MagoSalesStats::fetchAllQ($pdo, " + SELECT COALESCE(SUM(r.residual_value), 0) AS v, + COUNT(*) AS n_lines, + COUNT(DISTINCT r.sale_ord_id) AS n_orders, + COALESCE(SUM(CASE WHEN r.due_date < CURDATE() THEN r.residual_value END), 0) AS late_v + FROM " . MagoSalesStats::openLinesFrom() . " + WHERE $rWhere + ", $params)[0]; + + // ordinato: ultimi 90 giorni, mesi (24) e anni per la previsione + $dash['ordered_90'] = (float)MagoSalesStats::fetchAllQ($pdo, " + SELECT COALESCE(SUM($val), 0) AS v FROM $from WHERE $where AND x.order_date > CURDATE() - INTERVAL 90 DAY + ", $params)[0]['v']; + $since24 = date('Y-m-01', strtotime('first day of -23 months')); + $dash['since'] = $since24; + $dash['ordered_months'] = MagoSalesStats::fetchAllQ($pdo, " + SELECT DATE_FORMAT(x.order_date, '%Y-%m') AS ym, SUM($val) AS v + FROM $from WHERE $where AND x.order_date >= '$since24' + GROUP BY ym ORDER BY ym + ", $params); + $ordYears = MagoSalesStats::fetchAllQ($pdo, " + SELECT YEAR(x.order_date) AS y, SUM($val) AS full, + 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 $where AND x.order_date >= '" . ($curY - 3) . "-01-01' + GROUP BY y + ", $params); + $dash['ordered_forecast'] = MagoSalesStats::yearForecast($ordYears, $curY); + + // fatturato (se i documenti sono sincronizzati) + $dash['invoiced_90'] = null; + $dash['invoiced_months'] = []; + $dash['invoiced_forecast'] = null; + if (!empty($invoiced['available'])) { + $dash['invoiced_90'] = (float)MagoSalesStats::fetchAllQ($pdo, " + SELECT COALESCE(SUM(i.revenue), 0) AS v FROM $iFrom WHERE $iWhere AND i.document_date > CURDATE() - INTERVAL 90 DAY + ", $params)[0]['v']; + $dash['invoiced_months'] = MagoSalesStats::fetchAllQ($pdo, " + SELECT DATE_FORMAT(i.document_date, '%Y-%m') AS ym, SUM(i.revenue) AS v + FROM $iFrom WHERE $iWhere AND i.document_date >= '$since24' + GROUP BY ym ORDER BY ym + ", $params); + $invYears = 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 $iWhere AND i.document_date >= '" . ($curY - 3) . "-01-01' + GROUP BY y + ", $params); + $dash['invoiced_forecast'] = MagoSalesStats::yearForecast($invYears, $curY); + } + + // ===== CLIENTI DA RICHIAMARE ===== + $dormRows = MagoSalesStats::fetchAllQ($pdo, " + SELECT x.customer AS code, x.company_name AS name, x.order_date, x.taxable_amount AS v, + x.salesperson, x.area, x.internal_ord_no AS ord_no + FROM $from + WHERE $where AND x.status <> 'annullato' AND x.customer <> '' + AND x.order_date >= CURDATE() - INTERVAL " . (int)MagoSalesStats::DORMANT_WINDOW_YEARS . " YEAR + ", $params); + $dormant = MagoSalesStats::dormantCustomers($dormRows); + MagoSalesStats::jsonOut([ 'success' => true, + 'dash' => $dash, + 'dormant' => $dormant, + 'dormant_cfg' => [ + 'years' => MagoSalesStats::DORMANT_WINDOW_YEARS, + 'min_orders' => MagoSalesStats::DORMANT_MIN_ORDERS, + 'watch' => MagoSalesStats::DORMANT_WATCH, + 'sleep' => MagoSalesStats::DORMANT_SLEEP, + 'lost' => MagoSalesStats::DORMANT_LOST, + ], 'invoiced' => $invoiced, 'metric' => $metric, 'ytd' => $ytd, @@ -513,6 +589,166 @@ if ($action === 'data') { 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; @@ -783,6 +1019,18 @@ if ($action === 'data') { + +
+ Situazione ad oggi + non dipende dagli anni scelti · segue i filtri cliente, agente e area +
+
+
+
Ordinato vs fatturato · ultimi 24 mesi
+
Barre: valori del mese · linea: book-to-bill su 3 mesi (ordinato ÷ fatturato: sopra 1 il portafoglio cresce, sotto 1 si consuma)
+
+
+
@@ -846,6 +1094,26 @@ if ($action === 'data') { + +
+
+
+
Clienti da richiamare
+
 
+
+
+ + + + +
+
+
+
+
+
+
+
@@ -1058,8 +1326,10 @@ if ($action === 'data') { renderCards(d); renderMonthly(d); renderCumul(d); + renderDash(d); renderInvoiced(d); renderInvTables(); + renderDormant(d); renderCustomers(d); }).catch(e => { $('#content').css('opacity', 1); @@ -1269,6 +1539,309 @@ if ($action === 'data') { }); } + /* ---------------- CRUSCOTTO DIREZIONE ---------------- */ + function renderDash(d) { + const x = d.dash || {}; + const b = x.backlog || {}; + const invOk = !!(d.invoiced && d.invoiced.available); + const cards = []; + + // 1) portafoglio da consegnare + const weekly = invOk && x.invoiced_90 > 0 ? x.invoiced_90 / (90 / 7) : (x.ordered_90 > 0 ? x.ordered_90 / (90 / 7) : 0); + const weeks = weekly > 0 && +b.v > 0 ? (+b.v / weekly) : null; + cards.push(` +
+
+
Portafoglio da consegnare
+
${fmtEurShort(+b.v || 0)}
+
${fmtNum(b.n_lines || 0)} righe · ${fmtNum(b.n_orders || 0)} ordini${weeks !== null ? `
≈ ${fmtNum(Math.round(weeks))} settimane di ${invOk ? 'fatturato' : 'ordinato'} medio` : ''}${+b.late_v > 0 ? `
${fmtEurShort(+b.late_v)} già oltre data ZIBO` : ''}
+
`); + + // 2) book-to-bill 90 giorni + if (invOk && x.invoiced_90 > 0) { + const r = x.ordered_90 / x.invoiced_90; + const cls = r >= 1.05 ? 'up' : (r <= 0.95 ? 'down' : 'flat'); + const lbl = cls === 'up' ? 'portafoglio in crescita' : (cls === 'down' ? 'portafoglio in calo' : 'stabile'); + cards.push(` +
+
+
Book-to-bill · 90 gg
+
${r.toLocaleString('it-IT', {minimumFractionDigits: 2, maximumFractionDigits: 2})}${lbl}
+
Ordinato ${fmtEurShort(x.ordered_90)} · fatturato ${fmtEurShort(x.invoiced_90)}
+
`); + } else { + cards.push(dashNa('Book-to-bill · 90 gg', 'fa-scale-balanced', invOk ? 'nessun fatturato negli ultimi 90 giorni' : 'serve la sincronizzazione delle fatture')); + } + + // 3-4) previsioni di fine anno + cards.push(forecastCard('Previsione fatturato ' + CUR_Y, 'fa-file-invoice-dollar', '#0f766e', invOk ? x.invoiced_forecast : null, invOk ? '' : 'serve la sincronizzazione delle fatture')); + cards.push(forecastCard('Previsione ordinato ' + CUR_Y, 'fa-chart-line', '#6f42c1', x.ordered_forecast, '')); + + $('#dashCards').html(cards.join('')); + renderB2BChart(x); + } + + function dashNa(label, icon, msg) { + return `
+
${label}
—
${esc(msg)}
`; + } + + function forecastCard(label, icon, color, f, naMsg) { + if (!f || f.forecast === null || f.forecast === undefined) { + return dashNa(label, icon, naMsg || 'servono dati degli anni precedenti'); + } + const vsPrev = f.prev_full ? pct(f.forecast, f.prev_full) : null; + return ` +
+
+
${label}
+
${fmtEurShort(f.forecast)}
+
forbice ${fmtEurShort(f.low)} – ${fmtEurShort(f.high)}
+ ${f.prev_full ? `${CUR_Y - 1}: ${fmtEurShort(f.prev_full)} ${deltaSpan(vsPrev, '')}` : ''}
+ ad oggi ${fmtEurShort(f.ytd)} · stagionalità ${f.years.join('-')}
+
`; + } + + function renderB2BChart(x) { + const keys = []; + const start = new Date(x.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] = +r.v); + return m; + }; + const om = toMap(x.ordered_months), + im = toMap(x.invoiced_months); + const ord = keys.map(k => om[k] || 0); + const inv = keys.map(k => im[k] || 0); + const hasInv = (x.invoiced_months || []).length > 0; + // book-to-bill mobile su 3 mesi + const b2b = keys.map((k, i) => { + if (!hasInv || i < 2) return null; + const o = ord[i] + ord[i - 1] + ord[i - 2], + f = inv[i] + inv[i - 1] + inv[i - 2]; + return f > 0 ? Math.round(o / f * 100) / 100 : null; + }); + const cats = keys.map(k => MONTHS[+k.substring(5) - 1] + ' ' + k.substring(2, 4)); + + const series = [{ + name: 'Ordinato', + type: 'column', + data: ord + }]; + if (hasInv) series.push({ + name: 'Fatturato', + type: 'column', + data: inv + }, { + name: 'Book-to-bill 3 mesi', + type: 'line', + data: b2b + }); + + const yaxis = [{ + seriesName: 'Ordinato', + labels: { + formatter: fmtEurShort + } + }]; + if (hasInv) yaxis.push({ + seriesName: 'Ordinato', + show: false + }, { + seriesName: 'Book-to-bill 3 mesi', + opposite: true, + min: 0, + max: v => Math.max(2, Math.ceil((v || 0) * 2) / 2), + tickAmount: 4, + labels: { + formatter: v => v === null || v === undefined ? '' : v.toLocaleString('it-IT', { + maximumFractionDigits: 2 + }) + } + }); + + upsert('b2b', 'chB2B', { + chart: { + ...base, + type: 'line', + height: 320, + stacked: false + }, + series: series, + colors: ['#6f42c1', '#0f766e', '#f59e0b'], + stroke: { + width: hasInv ? [0, 0, 3] : [0], + curve: 'smooth' + }, + plotOptions: { + bar: { + columnWidth: '62%', + borderRadius: 3 + } + }, + markers: { + size: hasInv ? [0, 0, 3] : [0] + }, + dataLabels: { + enabled: false + }, + xaxis: { + categories: cats, + labels: { + rotate: -45, + hideOverlappingLabels: true + } + }, + yaxis: yaxis, + annotations: hasInv ? { + yAxis: [{ + y: 1, + yAxisIndex: 2, + borderColor: '#f59e0b', + strokeDashArray: 4, + label: { + text: 'parità 1,0', + style: { + background: '#fef3c7', + color: '#92400e' + } + } + }] + } : {}, + tooltip: { + shared: true, + intersect: false, + y: { + formatter: (v, o) => v === null || v === undefined ? '—' : (o && o.seriesIndex === 2 ? v.toLocaleString('it-IT', { + minimumFractionDigits: 2 + }) : fmtEur(v)) + } + }, + legend: { + position: 'top', + horizontalAlign: 'left' + }, + grid: { + borderColor: '#eef2f7' + } + }); + } + + /* ---------------- CLIENTI DA RICHIAMARE ---------------- */ + let dormDt = null, + dormLevel = ''; + const LVL = { + watch: 'Attenzione', + dormant: 'Dormiente', + lost: 'Probabilmente perso' + }; + + function renderDormant(d) { + const all = d.dormant || []; + const cfg = d.dormant_cfg || {}; + const cnt = { + watch: 0, + dormant: 0, + lost: 0 + }; + all.forEach(c => cnt[c.level]++); + $('#dCntAll').text(fmtNum(all.length)); + $('#dCntWatch').text(cnt.watch); + $('#dCntDormant').text(cnt.dormant); + $('#dCntLost').text(cnt.lost); + $('#dormChips .chip').removeClass('active'); + $(`#dormChips .chip[data-level="${dormLevel}"]`).addClass('active'); + $('#dormHint').text(`Clienti con almeno ${cfg.min_orders} ordini negli ultimi ${cfg.years} anni che ora tardano rispetto al loro ritmo abituale · clicca per filtrare la pagina`); + + const rows = all.filter(c => !dormLevel || c.level === dormLevel); + const risk = rows.reduce((a, c) => a + (+c.value12 || 0), 0); + $('#dormSum').html(rows.length ? + `${fmtNum(rows.length)} clienti · ${fmtEurShort(risk)} di ordinato annuo a rischio (ordinato dei 12 mesi prima del loro ultimo ordine)` : + 'Nessun cliente da richiamare con i filtri attuali 👍'); + + if (dormDt) { + dormDt.destroy(); + $('#dormTable').empty(); + } + dormDt = $('#dormTable').DataTable({ + data: rows, + columns: [{ + title: 'Cliente', + data: null, + render: (c, type) => type === 'display' ? + `
${esc(c.name)}
${esc(c.code)}
` : (type === 'export' ? `${c.name} (${c.code})` : `${c.name} ${c.code}`) + }, { + title: 'Agente', + data: 'salesperson' + }, { + title: 'Area', + data: 'area' + }, { + title: 'Ultimo ordine', + data: 'last_date', + render: (v, type, c) => { + if (type === 'export') return v; + if (type !== 'display') return v; + const p = v.split('-'); + return `
${p[2]}/${p[1]}/${p[0]} ${esc(c.last_no)}
${fmtNum(c.days_since)} giorni fa
`; + } + }, { + title: 'Ritmo abituale', + data: 'rhythm', + className: 'text-end', + render: (v, type) => type === 'display' ? `ogni ~${fmtNum(v)} gg` : +v + }, { + title: 'Ritardo', + data: 'ratio', + className: 'text-end text-nowrap', + render: (v, type, c) => { + if (type !== 'display') return +v; + const w = Math.min(100, v / (cfg.lost || 4) * 100); + const col = c.level === 'lost' ? '#dc2626' : (c.level === 'dormant' ? '#ea580c' : '#ca8a04'); + return `${String(v).replace('.', ',')}×`; + } + }, { + title: `Ordini ${cfg.years} anni`, + data: 'orders', + className: 'text-end' + }, { + title: 'Ordinato 12 mesi', + data: 'value12', + className: 'text-end amount', + render: (v, type) => type === 'display' ? fmtEur(+v) : +v + }, { + title: 'Stato', + data: 'level', + render: (v, type) => type === 'display' ? `${LVL[v]}` : LVL[v] + }], + order: [ + [7, 'desc'] + ], + pageLength: 10, + lengthMenu: [10, 25, 50, 100], + dom: "<'row mb-2'<'col-md-6'B><'col-md-6'f>>rt<'row mt-2'<'col-md-5'i><'col-md-7'p>>", + buttons: xlsButton('Clienti da richiamare' + (dormLevel ? ' - ' + LVL[dormLevel] : '') + invFiltersLabel(true)), + language: { + url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json' + } + }); + + $('#dormTable tbody').off('click').on('click', 'tr', function() { + const c = dormDt.row(this).data(); + if (!c) return; + $('#fCustomers').val([c.code]).trigger('change'); + window.scrollTo({ + top: 0, + behavior: 'smooth' + }); + }); + } + /* ---------------- TABELLA DATI FATTURATO (a scomparsa) ---------------- */ let invMonthDt = null, invDocDt = null, @@ -1307,7 +1880,7 @@ if ($action === 'data') { return $('#invToggle').attr('aria-expanded') === 'true'; } - function invFiltersLabel() { + function invFiltersLabel(noYtd) { const parts = []; const n = (id, l) => { const v = $(id).val() || []; @@ -1316,7 +1889,7 @@ if ($action === 'data') { n('#fCustomers', 'clienti'); n('#fSalespeople', 'agenti'); n('#fAreas', 'aree'); - if (lastData && lastData.ytd) parts.push(`a pari data ${todayLabel()}`); + if (!noYtd && lastData && lastData.ytd) parts.push(`a pari data ${todayLabel()}`); return parts.length ? ' (' + parts.join(' · ') + ')' : ''; } @@ -1715,6 +2288,12 @@ if ($action === 'data') { scheduleLoad(0); }); + // clienti da richiamare: filtro per livello + $('#dormChips').on('click', '.chip', function() { + dormLevel = $(this).data('level') || ''; + if (lastData) renderDormant(lastData); + }); + // tabella dati fatturato: apri/chiudi (stato ricordato) e schede $('#invToggle').on('click', function() { const open = !invOpen();