From ab7dd0f2f95b30680cbd2039e4559109a2a07732 Mon Sep 17 00:00:00 2001 From: solocla Date: Thu, 15 Oct 2026 08:50:10 +0200 Subject: [PATCH] added export to year --- public/userarea/class/MagoSalesStats.php | 7 + public/userarea/sales_orders_years.php | 402 ++++++++++++++++++++++- 2 files changed, 408 insertions(+), 1 deletion(-) diff --git a/public/userarea/class/MagoSalesStats.php b/public/userarea/class/MagoSalesStats.php index b4788334..6a68b280 100644 --- a/public/userarea/class/MagoSalesStats.php +++ b/public/userarea/class/MagoSalesStats.php @@ -70,6 +70,13 @@ class MagoSalesStats 3407876 => -1, // Nota di credito (TD04) → in negativo serie D ... ]; + /** Etichette dei tipi documento (tabella documenti). */ + const INVOICE_TYPE_LABELS = [ + 3407874 => 'Fattura', + 3407883 => 'Fattura d\'acconto', + 3407876 => 'Nota di credito', + ]; + /** Vista fatture/note di credito (alias i) con imponibile già col segno giusto (revenue). */ public static function invoiceFrom() { diff --git a/public/userarea/sales_orders_years.php b/public/userarea/sales_orders_years.php index ef19c96d..3bd6f072 100644 --- a/public/userarea/sales_orders_years.php +++ b/public/userarea/sales_orders_years.php @@ -31,6 +31,40 @@ if ($action === 'filters') { MagoSalesStats::jsonOut(MagoSalesStats::filterOptions($pdo)); } +// Elenco fatture / note di credito degli anni scelti (tabella a scomparsa sotto i grafici del fatturato) +if ($action === 'invdocs') { + try { + [$where, $params] = MagoSalesStats::buildWhere(['from' => '', 'to' => '']); + $iWhere = preg_replace('/\bx\./', 'i.', $where); + $iYtd = !empty($_GET['ytd']) ? " AND DATE_FORMAT(i.document_date, '%m-%d') <= DATE_FORMAT(CURDATE(), '%m-%d')" : ''; + + $years = array_values(array_unique(array_map('intval', MagoSalesStats::arrParam('years')))); + $years = array_filter($years, function ($y) { + return $y >= 2000 && $y <= 2100; + }); + if (!$years) MagoSalesStats::jsonOut(['success' => true, 'docs' => []]); + + $ph = []; + foreach (array_values($years) as $k => $y) { + $ph[] = ':dy' . $k; + $params['dy' . $k] = $y; + } + + $docs = MagoSalesStats::fetchAllQ($pdo, " + SELECT i.document_date, i.doc_no, i.document_type, i.is_credit_note, + i.customer, i.company_name, i.salesperson, i.area, i.revenue, i.no_amount + FROM " . MagoSalesStats::invoiceFrom() . " + WHERE $iWhere AND YEAR(i.document_date) IN (" . implode(',', $ph) . ") $iYtd + ORDER BY i.document_date DESC, i.doc_no DESC + LIMIT 20000 + ", $params); + + MagoSalesStats::jsonOut(['success' => true, 'docs' => $docs, 'types' => MagoSalesStats::INVOICE_TYPE_LABELS]); + } catch (Throwable $e) { + MagoSalesStats::jsonOut(['success' => false, 'message' => $e->getMessage()]); + } +} + if ($action === 'data') { try { $from = MagoSalesStats::baseFrom(); @@ -479,6 +513,61 @@ if ($action === 'data') { text-transform: uppercase; } + .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; @@ -733,6 +822,30 @@ if ($action === 'data') { +
+ + +
+
@@ -946,6 +1059,7 @@ if ($action === 'data') { renderMonthly(d); renderCumul(d); renderInvoiced(d); + renderInvTables(); renderCustomers(d); }).catch(e => { $('#content').css('opacity', 1); @@ -1155,10 +1269,253 @@ if ($action === 'data') { }); } + /* ---------------- TABELLA DATI FATTURATO (a scomparsa) ---------------- */ + let invMonthDt = null, + invDocDt = null, + invTab = 'months', + invDocsKey = ''; + + const xlsButton = title => ({ + dom: { + button: { + className: 'btn' + } + }, + buttons: [{ + extend: 'excelHtml5', + text: ' Excel', + className: 'btn btn-outline-success btn-sm', + title: title, + footer: true, + exportOptions: { + orthogonal: 'export', + format: { + header: h => $('
').html(h).text().replace('●', '').trim(), + // importi del totale come numeri in Excel; testo e percentuali lasciati com'erano + footer: h => { + const t = $('
').html(h).text().replace(/\u00a0/g, ' ').trim(); + if (/^-?[\d.]+(,\d+)?\s*€$/.test(t)) return t.replace(/\./g, '').replace('€', '').replace(',', '.').trim(); + if (/^[+-]?\d+%$/.test(t)) return parseInt(t, 10) / 100; + return t; + } + } + } + }] + }); + + function invOpen() { + return $('#invToggle').attr('aria-expanded') === 'true'; + } + + function invFiltersLabel() { + const parts = []; + const n = (id, l) => { + const v = $(id).val() || []; + if (v.length) parts.push(`${l}: ${v.join(', ')}`); + }; + n('#fCustomers', 'clienti'); + n('#fSalespeople', 'agenti'); + n('#fAreas', 'aree'); + if (lastData && lastData.ytd) parts.push(`a pari data ${todayLabel()}`); + return parts.length ? ' (' + parts.join(' · ') + ')' : ''; + } + + function renderInvTables() { + if (!lastData || !lastData.invoiced || !lastData.invoiced.available || !invOpen()) return; + if (invTab === 'months') renderInvMonthTable(lastData); + else loadInvDocs(); + } + + function renderInvMonthTable(d) { + const years = d.years.slice().reverse(); + const map = seriesByYear(d, d.invoiced.monthly || []); + const rows = MONTHS.map((m, i) => ({ + m: i, + label: m + })); + const cum = {}; + years.forEach(y => { + let s = 0; + cum[y] = map[y].map(v => v === null ? null : (s += v)); + }); + + const money = v => v === null || v === undefined ? '—' : fmtEur(v); + const columns = [{ + title: 'Mese', + data: 'label', + render: (v, type, r) => type === 'display' || type === 'export' ? v.charAt(0).toUpperCase() + v.slice(1) : r.m + }]; + years.forEach((y, k) => { + columns.push({ + title: `● ${y}`, + data: null, + className: 'text-end amount', + render: (r, type) => { + const v = map[y][r.m]; + return type === 'display' ? money(v) : (v === null ? '' : Math.round(v * 100) / 100); + } + }, { + title: `${y} progr.`, + data: null, + className: 'text-end text-muted', + render: (r, type) => { + const v = cum[y][r.m]; + return type === 'display' ? money(v) : (v === null ? '' : Math.round(v * 100) / 100); + } + }); + const py = years[k + 1]; + if (k === 0 && py) { + columns.push({ + title: `Var. ${y} vs ${py}`, + data: null, + className: 'text-end', + render: (r, type) => { + const a = map[y][r.m], + b = map[py][r.m]; + const p = (a === null || !b) ? null : (a - b) / Math.abs(b) * 100; + if (type === 'export') return p === null ? '' : Math.round(p) / 100; + if (type !== 'display') return p === null ? -1e9 : p; + if (p === null) return '—'; + const rr = Math.round(p); + return `${rr >= 0 ? '+' : ''}${rr}%`; + } + }); + } + }); + + // riga totale + const tot = years.map(y => map[y].reduce((a, v) => a + (v || 0), 0)); + let foot = 'Totale'; + years.forEach((y, k) => { + foot += `${fmtEur(tot[k], 2)}`; + if (k === 0 && years[1]) { + const p = tot[1] ? (tot[0] - tot[1]) / Math.abs(tot[1]) * 100 : null; + foot += `${p === null ? '' : `${p >= 0 ? '+' : ''}${Math.round(p)}%`}`; + } + }); + foot += ''; + + if (invMonthDt) { + invMonthDt.destroy(); + $('#invMonthTable').empty(); + } + $('#invMonthTable').html(foot); + invMonthDt = $('#invMonthTable').DataTable({ + data: rows, + columns: columns, + order: [ + [0, 'asc'] + ], + paging: false, + searching: false, + info: false, + dom: "<'mb-2'B>rt", + buttons: xlsButton('Fatturato per mese' + invFiltersLabel()), + language: { + url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json' + } + }); + } + + function loadInvDocs() { + const p = new URLSearchParams(); + p.append('action', 'invdocs'); + if (lastData.ytd) p.append('ytd', '1'); + lastData.years.forEach(y => p.append('years[]', y)); + [ + ['customers', '#fCustomers'], + ['salespeople', '#fSalespeople'], + ['areas', '#fAreas'] + ].forEach(([k, id]) => ($(id).val() || []).forEach(v => p.append(k + '[]', v))); + const key = p.toString(); + if (key === invDocsKey && invDocDt) return; // già caricati con questi filtri + invDocsKey = key; + + $('#invPaneDocs').css('opacity', .5); + fetch('?' + key).then(r => r.json()).then(res => { + $('#invPaneDocs').css('opacity', 1); + if (!res.success) { + alert(res.message || 'Errore nel caricamento documenti'); + return; + } + renderInvDocTable(res.docs || [], res.types || {}); + }).catch(e => { + $('#invPaneDocs').css('opacity', 1); + console.error(e); + }); + } + + function renderInvDocTable(docs, types) { + const cls = r => +r.is_credit_note ? 't-cn' : (+r.document_type === 3407874 ? 't-inv' : 't-adv'); + const total = docs.reduce((a, r) => a + (+r.revenue || 0), 0); + const nc = docs.filter(r => +r.is_credit_note).length; + + if (invDocDt) { + invDocDt.destroy(); + $('#invDocTable').empty(); + } + $('#invDocTable').html(`Totale${fmtNum(docs.length - nc)} fatture${fmtNum(nc)} note di credito${fmtEur(total, 2)}`); + invDocDt = $('#invDocTable').DataTable({ + data: docs, + columns: [{ + title: 'Data', + data: 'document_date', + render: (v, type) => type === 'display' ? fmtDateIt(v) : v + }, { + title: 'N° documento', + data: 'doc_no', + className: 'text-nowrap' + }, { + title: 'Tipo', + data: 'document_type', + render: (v, type, r) => { + const l = types[v] || v; + return type === 'display' ? `${esc(l)}` : l; + } + }, { + title: 'Cliente', + data: null, + render: (r, type) => type === 'display' ? + `
${esc(r.company_name)}
${esc(r.customer)}
` : (type === 'export' ? `${r.company_name} (${r.customer})` : `${r.company_name} ${r.customer}`) + }, { + title: 'Agente', + data: 'salesperson' + }, { + title: 'Area', + data: 'area' + }, { + title: 'Imponibile', + data: 'revenue', + className: 'text-end amount', + render: (v, type, r) => { + if (type !== 'display') return +v; + if (+r.no_amount) return '—'; + return `${fmtEur(+v, 2)}`; + } + }], + order: [ + [0, 'desc'] + ], + pageLength: 25, + lengthMenu: [10, 25, 50, 100, 500], + 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('Fatture e note di credito' + invFiltersLabel()), + language: { + url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json' + } + }); + } + + function fmtDateIt(v) { + if (!v) return ''; + const p = String(v).substring(0, 10).split('-'); + return `${p[2]}/${p[1]}/${p[0]}`; + } + // fatturato: sempre in € (indipendente dal selettore N° ordini / Valore) function renderInvoiced(d) { const inv = d.invoiced || {}; - $('#invRow').toggle(!!inv.available); + $('#invRow, #invTableCard').toggle(!!inv.available); const miss = (inv.summary || []).filter(r => d.years.includes(+r.y)).reduce((a, r) => a + (+r.missing_amounts || 0), 0); $('#invMissing').toggle(!inv.available || miss > 0).html(!inv.available ? ' Fatturato non ancora disponibile: eseguire la migrazione mago_sale_doc e una sincronizzazione.' : @@ -1358,6 +1715,49 @@ if ($action === 'data') { scheduleLoad(0); }); + // tabella dati fatturato: apri/chiudi (stato ricordato) e schede + $('#invToggle').on('click', function() { + const open = !invOpen(); + $(this).attr('aria-expanded', open ? 'true' : 'false'); + $('#invTableBody').stop(true, true)[open ? 'slideDown' : 'slideUp'](180, () => { + if (open) renderInvTables(); + }); + try { + localStorage.setItem(STORE_KEY + '_inv', JSON.stringify({ + open: open, + tab: invTab + })); + } catch (e) {} + }); + $('#invTabs').on('click', 'button', function() { + invTab = $(this).data('tab'); + $('#invTabs button').removeClass('active'); + $(this).addClass('active'); + $('#invPaneMonths').toggle(invTab === 'months'); + $('#invPaneDocs').toggle(invTab === 'docs'); + renderInvTables(); + try { + localStorage.setItem(STORE_KEY + '_inv', JSON.stringify({ + open: invOpen(), + tab: invTab + })); + } catch (e) {} + }); + try { + const st = JSON.parse(localStorage.getItem(STORE_KEY + '_inv') || 'null'); + if (st) { + invTab = st.tab === 'docs' ? 'docs' : 'months'; + $('#invTabs button').removeClass('active'); + $(`#invTabs button[data-tab="${invTab}"]`).addClass('active'); + $('#invPaneMonths').toggle(invTab === 'months'); + $('#invPaneDocs').toggle(invTab === 'docs'); + if (st.open) { + $('#invToggle').attr('aria-expanded', 'true'); + $('#invTableBody').show(); + } + } + } catch (e) {} + $('#trendChips').on('click', '.chip', function() { trendFilter = $(this).data('trend') || ''; if (lastData) renderCustomers(lastData);