added export to year
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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') {
|
||||
</div>
|
||||
<div class="warn-strip mb-3" id="invMissing" style="display:none;"></div>
|
||||
|
||||
<div class="chart-card mb-3" id="invTableCard">
|
||||
<button type="button" class="inv-toggle" id="invToggle" aria-expanded="false">
|
||||
<span><i class="fa-solid fa-table me-2"></i>Tabella dati fatturato</span>
|
||||
<span class="small text-muted d-none d-md-inline">per mese e documenti · esportabile in Excel</span>
|
||||
<i class="fa-solid fa-chevron-down inv-chev"></i>
|
||||
</button>
|
||||
<div id="invTableBody" style="display:none;">
|
||||
<div class="metric-switch my-3" id="invTabs">
|
||||
<button type="button" data-tab="months" class="active"><i class="fa-regular fa-calendar me-1"></i> Per mese</button>
|
||||
<button type="button" data-tab="docs"><i class="fa-regular fa-file-lines me-1"></i> Documenti</button>
|
||||
</div>
|
||||
<div id="invPaneMonths">
|
||||
<div class="table-responsive">
|
||||
<table id="invMonthTable" class="table table-sm table-striped align-middle w-100"></table>
|
||||
</div>
|
||||
</div>
|
||||
<div id="invPaneDocs" style="display:none;">
|
||||
<div class="table-responsive">
|
||||
<table id="invDocTable" class="table table-sm table-striped align-middle w-100"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CLIENTI -->
|
||||
<div class="chart-card">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
|
||||
@@ -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: '<i class="fa-solid fa-file-excel me-1"></i> Excel',
|
||||
className: 'btn btn-outline-success btn-sm',
|
||||
title: title,
|
||||
footer: true,
|
||||
exportOptions: {
|
||||
orthogonal: 'export',
|
||||
format: {
|
||||
header: h => $('<div>').html(h).text().replace('●', '').trim(),
|
||||
// importi del totale come numeri in Excel; testo e percentuali lasciati com'erano
|
||||
footer: h => {
|
||||
const t = $('<div>').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 ? '<span class="text-muted">—</span>' : 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: `<span style="color:${yearColor(y)};">●</span> ${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 '<span class="text-muted">—</span>';
|
||||
const rr = Math.round(p);
|
||||
return `<span class="${rr >= 0 ? 'delta-up' : 'delta-down'}">${rr >= 0 ? '+' : ''}${rr}%</span>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// riga totale
|
||||
const tot = years.map(y => map[y].reduce((a, v) => a + (v || 0), 0));
|
||||
let foot = '<tfoot><tr><th>Totale</th>';
|
||||
years.forEach((y, k) => {
|
||||
foot += `<th class="text-end">${fmtEur(tot[k], 2)}</th><th></th>`;
|
||||
if (k === 0 && years[1]) {
|
||||
const p = tot[1] ? (tot[0] - tot[1]) / Math.abs(tot[1]) * 100 : null;
|
||||
foot += `<th class="text-end">${p === null ? '' : `<span class="${p >= 0 ? 'delta-up' : 'delta-down'}">${p >= 0 ? '+' : ''}${Math.round(p)}%</span>`}</th>`;
|
||||
}
|
||||
});
|
||||
foot += '</tr></tfoot>';
|
||||
|
||||
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(`<tfoot><tr><th>Totale</th><th>${fmtNum(docs.length - nc)} fatture</th><th>${fmtNum(nc)} note di credito</th><th></th><th></th><th></th><th class="text-end">${fmtEur(total, 2)}</th></tr></tfoot>`);
|
||||
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' ? `<span class="doc-type ${cls(r)}">${esc(l)}</span>` : l;
|
||||
}
|
||||
}, {
|
||||
title: 'Cliente',
|
||||
data: null,
|
||||
render: (r, type) => type === 'display' ?
|
||||
`<div class="fw-semibold">${esc(r.company_name)}</div><div class="small text-muted">${esc(r.customer)}</div>` : (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 '<span class="text-muted" title="Importo non ancora sincronizzato">—</span>';
|
||||
return `<span class="${+v < 0 ? 'delta-down' : ''}">${fmtEur(+v, 2)}</span>`;
|
||||
}
|
||||
}],
|
||||
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 ?
|
||||
'<i class="fa-solid fa-circle-info me-1"></i> Fatturato non ancora disponibile: eseguire la migrazione <b>mago_sale_doc</b> 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);
|
||||
|
||||
Reference in New Issue
Block a user