added prefernce business overview

This commit is contained in:
2026-10-22 13:48:48 +02:00
parent 33c81e2ad1
commit c4a3e48dd2
12 changed files with 3777 additions and 206 deletions
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Preferenze di interfaccia per utente: ordine, larghezza e visibilità dei riquadri nelle pagine.
*/
final class CreateUserUiPrefs extends AbstractMigration
{
public function change(): void
{
$this->table('user_ui_prefs', ['comment' => 'Layout pagine personalizzato per utente'])
->addColumn('user_id', 'integer', ['signed' => false])
->addColumn('page', 'string', ['limit' => 50, 'comment' => 'chiave pagina, es. sales_orders_stats'])
->addColumn('layout', 'text', ['null' => true, 'comment' => 'JSON {order, hidden, sizes}'])
->addColumn('updated_at', 'datetime', ['null' => true])
->addIndex(['user_id', 'page'], ['unique' => true])
->create();
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
/**
* Preferenze di interfaccia per utente (posizione, larghezza e visibilità dei riquadri nelle pagine).
*
* Tabella user_ui_prefs: una riga per utente e pagina, layout in JSON:
* { "order": ["orders","monthly",...], "hidden": ["items"], "sizes": {"monthly": 12} }
*/
class UiPrefs
{
const SIZES = [4, 5, 6, 7, 8, 12];
public static function validPage(string $page): bool
{
return (bool)preg_match('/^[a-z0-9_]{1,50}$/', $page);
}
/** Layout salvato (array vuoto se non c'è o se la tabella non esiste ancora). */
public static function get(PDO $pdo, int $userId, string $page): array
{
if ($userId <= 0 || !self::validPage($page)) return [];
try {
$st = $pdo->prepare('SELECT layout FROM user_ui_prefs WHERE user_id = ? AND page = ?');
$st->execute([$userId, $page]);
$j = $st->fetchColumn();
$d = $j ? json_decode($j, true) : null;
return is_array($d) ? self::clean($d) : [];
} catch (Throwable $e) {
return [];
}
}
public static function save(PDO $pdo, int $userId, string $page, array $layout): bool
{
if ($userId <= 0 || !self::validPage($page)) return false;
$json = json_encode(self::clean($layout));
$st = $pdo->prepare('INSERT INTO user_ui_prefs (user_id, page, layout, updated_at) VALUES (?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE layout = VALUES(layout), updated_at = NOW()');
return $st->execute([$userId, $page, $json]);
}
public static function reset(PDO $pdo, int $userId, string $page): bool
{
if ($userId <= 0 || !self::validPage($page)) return false;
$st = $pdo->prepare('DELETE FROM user_ui_prefs WHERE user_id = ? AND page = ?');
return $st->execute([$userId, $page]);
}
/** Attributi HTML per il contenitore dei riquadri: data-layout + data-prefs. */
public static function attr(PDO $pdo, $userId, string $page): string
{
$p = self::get($pdo, (int)$userId, $page);
return 'data-layout="' . htmlspecialchars($page) . '" data-prefs="'
. htmlspecialchars(json_encode($p ?: new stdClass()), ENT_QUOTES) . '"';
}
private static function clean(array $d): array
{
$key = function ($k) {
return is_string($k) && preg_match('/^[a-z0-9_]{1,40}$/', $k);
};
$order = array_values(array_unique(array_filter((array)($d['order'] ?? []), $key)));
$hidden = array_values(array_unique(array_filter((array)($d['hidden'] ?? []), $key)));
$sizes = [];
foreach ((array)($d['sizes'] ?? []) as $k => $v) {
if ($key($k) && in_array((int)$v, self::SIZES, true)) $sizes[$k] = (int)$v;
}
return [
'order' => array_slice($order, 0, 60),
'hidden' => array_slice($hidden, 0, 60),
'sizes' => $sizes ?: new stdClass(),
];
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+36 -34
View File
@@ -5,6 +5,7 @@ error_reporting(E_ALL);
include('include/headscript.php');
require_once __DIR__ . '/class/MagoSalesStats.php';
require_once __DIR__ . '/class/UiPrefs.php';
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
@@ -1228,52 +1229,53 @@ if ($action === 'data') {
<!-- 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="d-flex justify-content-end mb-2"><button type="button" data-layout-btn="sales_customer"></button></div>
<!-- RIQUADRI (ordine personalizzabile per utente: ui_layout.js) -->
<div class="row g-3 mb-3" <?= UiPrefs::attr($pdo, $iduserlogin ?? 0, 'sales_customer') ?>>
<div class="col-xl-5" data-widget="years" data-title="Ordinato e fatturato per anno">
<div class="chart-card">
<div class="chart-title" data-help="sc.years">Ordinato e fatturato per anno</div>
<div class="chart-hint" id="yearsHint">Anno in corso: valori ad oggi</div>
<div id="chYears" style="min-height:300px;"></div>
</div>
</div>
<div class="col-xl-7">
<div class="col-xl-7" data-widget="months" data-title="Ultimi 24 mesi">
<div class="chart-card">
<div class="chart-title" data-help="sc.months">Ultimi 24 mesi</div>
<div class="chart-hint">Ordinato per data ordine, fatturato per data fattura</div>
<div id="chMonths" style="min-height:300px;"></div>
</div>
</div>
</div>
<!-- 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 class="col-12" data-widget="tables" data-title="Ordini, articoli e fatture">
<div class="chart-card">
<div class="sc-tabs" id="scTabs">
<button type="button" data-tab="open" class="active"><i class="fa-solid fa-truck-ramp-box me-1"></i> Da consegnare <span class="badge bg-light text-dark" id="nOpen">0</span></button>
<button type="button" data-tab="orders"><i class="fa-solid fa-file-invoice me-1"></i> Ordini <span class="badge bg-light text-dark" id="nOrders">0</span></button>
<button type="button" data-tab="items"><i class="fa-solid fa-cubes me-1"></i> Articoli <span class="badge bg-light text-dark" id="nItems">0</span></button>
<button type="button" data-tab="docs"><i class="fa-solid fa-receipt me-1"></i> Fatture <span class="badge bg-light text-dark" id="nDocs">0</span></button>
<span class="ms-auto align-self-center pe-2" data-help="sc.tables"></span>
</div>
<div class="sc-pane" data-pane="open">
<div class="table-responsive">
<table id="tOpen" class="table table-striped align-middle w-100 sc-table"></table>
</div>
</div>
<div class="sc-pane" data-pane="orders" style="display:none;">
<div class="table-responsive">
<table id="tOrders" class="table table-striped align-middle w-100 sc-table"></table>
</div>
</div>
<div class="sc-pane" data-pane="items" style="display:none;">
<div class="chart-hint mb-2">Prezzo medio = imponibile ÷ quantità. La variazione confronta gli ultimi 12 mesi con i 12 precedenti.</div>
<div class="table-responsive">
<table id="tItems" class="table table-striped align-middle w-100 sc-table"></table>
</div>
</div>
<div class="sc-pane" data-pane="docs" style="display:none;">
<div class="table-responsive">
<table id="tDocs" class="table table-striped align-middle w-100 sc-table"></table>
</div>
</div>
</div>
</div>
</div>
+30 -28
View File
@@ -5,6 +5,7 @@ error_reporting(E_ALL);
include('include/headscript.php');
require_once __DIR__ . '/class/MagoSalesStats.php';
require_once __DIR__ . '/class/UiPrefs.php';
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
@@ -723,9 +724,10 @@ if ($action === 'data') {
</div>
</div>
<!-- GRAFICI -->
<div class="row g-3 mb-3">
<div class="col-xl-8">
<div class="d-flex justify-content-end mb-2"><button type="button" data-layout-btn="sales_orders_deliveries"></button></div>
<!-- RIQUADRI (ordine personalizzabile per utente: ui_layout.js) -->
<div class="row g-3 mb-3" <?= UiPrefs::attr($pdo, $iduserlogin ?? 0, 'sales_orders_deliveries') ?>>
<div class="col-xl-8" data-widget="weekly" data-title="Carico consegne per settimana">
<div class="chart-card">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
<div>
@@ -740,37 +742,37 @@ if ($action === 'data') {
<div id="chWeekly" style="min-height:340px;"></div>
</div>
</div>
<div class="col-xl-4">
<div class="col-xl-4" data-widget="latecust" data-title="Clienti con più ritardo">
<div class="chart-card">
<div class="chart-title" data-help="dl.lateCust">Clienti con più ritardo</div>
<div class="chart-hint">Valore residuo scaduto (data ZIBO) · clicca per filtrare</div>
<div id="chLateCust" style="min-height:340px;"></div>
</div>
</div>
</div>
<!-- RIGHE -->
<div class="chart-card">
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2">
<div class="chart-title" data-help="dl.rows">Righe da consegnare <span class="text-muted fw-normal" id="rowsInfo"></span></div>
<div class="chart-hint">Ordinate per scadenza<?= $canStats ? ' · clicca il numero ordine per aprirlo' : '' ?></div>
</div>
<div class="table-responsive">
<table id="linesTable" class="table table-striped align-middle w-100">
<thead>
<tr>
<th>Ordine</th>
<th>Cliente</th>
<th>Articolo</th>
<th class="text-end">Residuo</th>
<th>Consegna (ZIBO / cliente)</th>
<th>Ritardo</th>
<th class="text-end">Valore residuo</th>
<th>Agente</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="col-12" data-widget="rows" data-title="Righe da consegnare">
<div class="chart-card">
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2">
<div class="chart-title" data-help="dl.rows">Righe da consegnare <span class="text-muted fw-normal" id="rowsInfo"></span></div>
<div class="chart-hint">Ordinate per scadenza<?= $canStats ? ' · clicca il numero ordine per aprirlo' : '' ?></div>
</div>
<div class="table-responsive">
<table id="linesTable" class="table table-striped align-middle w-100">
<thead>
<tr>
<th>Ordine</th>
<th>Cliente</th>
<th>Articolo</th>
<th class="text-end">Residuo</th>
<th>Consegna (ZIBO / cliente)</th>
<th>Ritardo</th>
<th class="text-end">Valore residuo</th>
<th>Agente</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
+64 -53
View File
@@ -6,6 +6,7 @@ error_reporting(E_ALL);
include('include/headscript.php');
require_once __DIR__ . '/class/MagoSync.php';
require_once __DIR__ . '/class/MagoSalesStats.php';
require_once __DIR__ . '/class/UiPrefs.php';
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
@@ -1072,17 +1073,49 @@ if ($action === 'order') {
<div class="warn-strip mb-3" id="missingAmounts" style="display:none;"></div>
<!-- BARRA: metrica dei grafici + personalizza -->
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2">
<div class="chart-title" style="font-size:1.05rem;">Analisi</div>
<div class="metric-switch" id="metricSwitch">
<button type="button" data-metric="count" class="active"># N° ordini</button>
<button type="button" data-metric="value">€ Valore</button>
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="chart-title mb-0" style="font-size:1rem;">Grafici in</span>
<div class="metric-switch" id="metricSwitch">
<button type="button" data-metric="count" class="active"># N° ordini</button>
<button type="button" data-metric="value">€ Valore</button>
</div>
</div>
<button type="button" data-layout-btn="sales_orders_stats"></button>
</div>
<!-- GRAFICI -->
<div class="row g-3 mb-3">
<div class="col-xl-8">
<!-- RIQUADRI (ordine personalizzabile per utente: ui_layout.js) -->
<div class="row g-3 mb-4" <?= UiPrefs::attr($pdo, $iduserlogin ?? 0, 'sales_orders_stats') ?>>
<!-- ELENCO ORDINI -->
<div class="col-12" data-widget="orders" data-title="Elenco ordini">
<div class="chart-card">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="chart-title" data-help="st.list">Elenco ordini <span class="text-muted fw-normal" id="rowsInfo"></span></div>
<div class="chart-hint">Clicca una riga per il dettaglio</div>
</div>
<div class="table-responsive">
<table id="ordersTable" class="table table-striped align-middle w-100">
<thead>
<tr>
<th>N° ordine</th>
<th>Data</th>
<th>Cliente</th>
<th>Rif. cliente</th>
<th>Consegna <span class="small fw-normal">(ZIBO / cliente)</span></th>
<th class="text-end">Imponibile</th>
<th>Stato</th>
<th>Fatt.</th>
<th>Agente</th>
<th>Area</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<div class="col-xl-8" data-widget="monthly" data-title="Andamento mensile">
<div class="chart-card">
<div class="d-flex justify-content-between align-items-start">
<div>
@@ -1093,83 +1126,47 @@ if ($action === 'order') {
<div id="chMonthly" style="min-height:320px;"></div>
</div>
</div>
<div class="col-xl-4">
<div class="col-xl-4" data-widget="status" data-title="Ripartizione per stato">
<div class="chart-card">
<div class="chart-title" data-help="st.status">Ripartizione per stato</div>
<div class="chart-hint">Clicca una fetta per filtrare</div>
<div id="chStatus" style="min-height:320px;"></div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-xl-7">
<div class="col-xl-7" data-widget="customers" data-title="Top clienti">
<div class="chart-card">
<div class="chart-title" id="ttlCustomers" data-help="st.topcust">Top 10 clienti per numero ordini</div>
<div class="chart-hint">Clicca un cliente per vedere solo i suoi ordini</div>
<div id="chCustomers" style="min-height:360px;"></div>
</div>
</div>
<div class="col-xl-5">
<div class="col-xl-5" data-widget="due" data-title="Scadenze ordini aperti">
<div class="chart-card">
<div class="chart-title" id="ttlDue" data-help="st.due">Scadenze ordini aperti</div>
<div class="chart-hint">In base alla data di consegna prevista</div>
<div id="chDue" style="min-height:360px;"></div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-12">
<div class="col-12" data-widget="items" data-title="Top articoli">
<div class="chart-card">
<div class="chart-title" id="ttlItems" data-help="st.items">Top 10 articoli per numero ordini</div>
<div class="chart-hint">Dalle righe ordine · passa sopra una barra per quantità e valore</div>
<div id="chItems" style="min-height:380px;"></div>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-lg-6">
<div class="col-lg-6" data-widget="area" data-title="Ordini per area">
<div class="chart-card">
<div class="chart-title" id="ttlArea" data-help="st.area">Ordini per area</div>
<div id="chArea" style="min-height:260px;"></div>
</div>
</div>
<div class="col-lg-6">
<div class="col-lg-6" data-widget="agent" data-title="Ordini per agente">
<div class="chart-card">
<div class="chart-title" id="ttlSalesperson" data-help="st.agent">Ordini per agente</div>
<div id="chSalesperson" style="min-height:260px;"></div>
</div>
</div>
</div>
<!-- TABELLA -->
<div class="chart-card">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="chart-title" data-help="st.list">Elenco ordini <span class="text-muted fw-normal" id="rowsInfo"></span></div>
<div class="chart-hint">Clicca una riga per il dettaglio</div>
</div>
<div class="table-responsive">
<table id="ordersTable" class="table table-striped align-middle w-100">
<thead>
<tr>
<th>N° ordine</th>
<th>Data</th>
<th>Cliente</th>
<th>Rif. cliente</th>
<th>Consegna <span class="small fw-normal">(ZIBO / cliente)</span></th>
<th class="text-end">Imponibile</th>
<th>Stato</th>
<th>Fatt.</th>
<th>Agente</th>
<th>Area</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
@@ -1382,10 +1379,18 @@ if ($action === 'order') {
}
// aperta da link (?q=numero ordine): non sovrascrivo i filtri salvati dell'utente
const URL_Q = new URLSearchParams(location.search).get('q');
const URL_P = new URLSearchParams(location.search);
const URL_Q = URL_P.get('q');
// aperta dal Sommario (?from=&to=&st=): periodo e stati del box cliccato, senza toccare i filtri salvati
const URL_RANGE = /^\d{4}-\d{2}-\d{2}$/.test(URL_P.get('from') || '') ? {
from: URL_P.get('from'),
to: /^\d{4}-\d{2}-\d{2}$/.test(URL_P.get('to') || '') ? URL_P.get('to') : URL_P.get('from'),
st: (URL_P.get('st') || '').split(',').filter(Boolean)
} : null;
const URL_LINK = !!(URL_Q || URL_RANGE);
function saveFilters(f) {
if (URL_Q) return;
if (URL_LINK) return;
try {
localStorage.setItem(STORE_KEY, JSON.stringify(Object.assign({}, f, {
metric: metric
@@ -2328,7 +2333,7 @@ if ($action === 'order') {
loadFilterOptions(false).then(d => {
window.__totalOrders = d && d.sync ? d.sync.total_orders : 0;
const saved = URL_Q ? null : restoreFilters();
const saved = URL_LINK ? null : restoreFilters();
if (saved && saved.metric === 'value') metric = 'value';
if (URL_Q) {
// tutto lo storico, solo l'ordine cercato; se è uno solo apro il dettaglio
@@ -2337,6 +2342,11 @@ if ($action === 'order') {
$('#periodChips .chip').removeClass('active');
$('#fSearch').val(URL_Q);
window.__autoOpen = URL_Q;
} else if (URL_RANGE) {
$('#fFrom').val(URL_RANGE.from);
$('#fTo').val(URL_RANGE.to);
$('#periodChips .chip').removeClass('active');
URL_RANGE.st.forEach(st => $(`#statusChips .chip[data-status="${st}"]`).addClass('active'));
}
if (saved && saved.from) {
$('#fFrom').val(saved.from);
@@ -2346,7 +2356,7 @@ if ($action === 'order') {
$('#fAreas').val(saved.areas || []).trigger('change.select2');
(saved.statuses || []).forEach(s => $(`#statusChips .chip[data-status="${s}"]`).addClass('active'));
$('#fSearch').val(saved.q || '');
} else if (!URL_Q) {
} else if (!URL_LINK) {
setPeriod('y' + new Date().getFullYear(), true);
}
loadData();
@@ -2355,6 +2365,7 @@ if ($action === 'order') {
})();
</script>
<script src="sales_help.js?v=3"></script>
<script src="ui_layout.js?v=1"></script>
</body>
</html>
+84 -84
View File
@@ -5,6 +5,7 @@ error_reporting(E_ALL);
include('include/headscript.php');
require_once __DIR__ . '/class/MagoSalesStats.php';
require_once __DIR__ . '/class/UiPrefs.php';
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
@@ -1033,120 +1034,119 @@ if ($action === 'data') {
<div id="content">
<div class="warn-strip mb-3" id="missingAmounts" style="display:none;"></div>
<!-- RIEPILOGO ANNI -->
<!-- CRUSCOTTO DIREZIONE: situazione ad oggi -->
<div class="dash-head">
<span data-help="yr.dash"><i class="fa-solid fa-gauge-high me-2"></i>Situazione ad oggi</span>
<small>non dipende dagli anni scelti · segue i filtri cliente, agente e area</small>
</div>
<div class="dash-grid mb-3" id="dashCards"></div>
<div class="chart-card mb-4">
<div class="chart-title" data-help="yr.b2bchart">Ordinato vs fatturato · ultimi 24 mesi</div>
<div class="chart-hint">Barre: valori del mese · linea: book-to-bill su 3 mesi (ordinato ÷ fatturato: sopra 1 il portafoglio cresce, sotto 1 si consuma)</div>
<div id="chB2B" style="min-height:320px;"></div>
</div>
<div class="d-flex justify-content-end mb-2"><button type="button" data-layout-btn="sales_orders_years"></button></div>
<div class="dash-head"><span data-help="yr.cards"><i class="fa-solid fa-layer-group me-2"></i>Anni a confronto</span></div>
<div class="year-grid mb-4" id="yearCards"></div>
<!-- GRAFICI -->
<div class="row g-3 mb-3">
<div class="col-xl-6">
<!-- RIQUADRI (ordine personalizzabile per utente: ui_layout.js) -->
<div class="row g-3 mb-4" <?= UiPrefs::attr($pdo, $iduserlogin ?? 0, 'sales_orders_years') ?>>
<div class="col-12" data-widget="today" data-title="Situazione ad oggi">
<div class="dash-head">
<span data-help="yr.dash"><i class="fa-solid fa-gauge-high me-2"></i>Situazione ad oggi</span>
<small>non dipende dagli anni scelti · segue i filtri cliente, agente e area</small>
</div>
<div class="dash-grid mb-3" id="dashCards"></div>
<div class="chart-card">
<div class="chart-title" data-help="yr.b2bchart">Ordinato vs fatturato · ultimi 24 mesi</div>
<div class="chart-hint">Barre: valori del mese · linea: book-to-bill su 3 mesi (ordinato ÷ fatturato: sopra 1 il portafoglio cresce, sotto 1 si consuma)</div>
<div id="chB2B" style="min-height:320px;"></div>
</div>
</div>
<div class="col-12" data-widget="years" data-title="Anni a confronto">
<div class="dash-head"><span data-help="yr.cards"><i class="fa-solid fa-layer-group me-2"></i>Anni a confronto</span></div>
<div class="year-grid" id="yearCards"></div>
</div>
<div class="col-xl-6" data-widget="monthly" data-title="Andamento mensile">
<div class="chart-card">
<div class="chart-title" id="ttlMonthly" data-help="yr.monthly">Andamento mensile</div>
<div class="chart-hint">Stagionalità: un colore per anno</div>
<div id="chMonthly" style="min-height:340px;"></div>
</div>
</div>
<div class="col-xl-6">
<div class="col-xl-6" data-widget="cumul" data-title="Progressivo dell'anno">
<div class="chart-card">
<div class="chart-title" id="ttlCumul" data-help="yr.cumul">Progressivo dell'anno</div>
<div class="chart-hint">Somma da gennaio: dice se si è avanti o indietro rispetto agli anni scorsi</div>
<div id="chCumul" style="min-height:340px;"></div>
</div>
</div>
</div>
<!-- FATTURATO (per data fattura) -->
<div class="row g-3 mb-3" id="invRow">
<div class="col-xl-6">
<div class="col-xl-6 inv-w" data-widget="invmonthly" data-title="Fatturato per mese">
<div class="chart-card">
<div class="chart-title" data-help="yr.invMonthly">Fatturato per mese</div>
<div class="chart-hint">Imponibile fatture + acconti − note di credito, per data fattura</div>
<div id="chInvMonthly" style="min-height:340px;"></div>
</div>
</div>
<div class="col-xl-6">
<div class="col-xl-6 inv-w" data-widget="invcumul" data-title="Fatturato progressivo">
<div class="chart-card">
<div class="chart-title" data-help="yr.invCumul">Fatturato progressivo dell'anno</div>
<div class="chart-hint">Somma da gennaio del fatturato, anno su anno</div>
<div id="chInvCumul" style="min-height:340px;"></div>
</div>
</div>
</div>
<div class="warn-strip mb-3" id="invMissing" style="display:none;"></div>
<div class="chart-card mb-3" id="invTableCard">
<div class="inv-toggle" id="invToggle" role="button" tabindex="0" aria-expanded="false">
<span data-help="yr.invTable"><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>
</div>
<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 class="col-12" data-widget="invtable" data-title="Tabella dati fatturato">
<div class="warn-strip mb-3" id="invMissing" style="display:none;"></div>
<div class="chart-card" id="invTableCard">
<div class="inv-toggle" id="invToggle" role="button" tabindex="0" aria-expanded="false">
<span data-help="yr.invTable"><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>
</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 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>
</div>
</div>
<!-- CLIENTI DA RICHIAMARE -->
<div class="chart-card mb-3" id="dormCard">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-2">
<div>
<div class="chart-title" data-help="yr.dormant"><i class="fa-solid fa-phone-volume me-2" style="color:#c2410c;"></i>Clienti da richiamare</div>
<div class="chart-hint" id="dormHint">&nbsp;</div>
</div>
<div class="chip-row" id="dormChips">
<button type="button" class="chip active" data-level="" style="--yc:#198754;">Tutti <span class="badge bg-light text-dark" id="dCntAll">0</span></button>
<button type="button" class="chip" data-level="watch" style="--yc:#ca8a04;">Attenzione <span class="badge bg-light text-dark" id="dCntWatch">0</span></button>
<button type="button" class="chip" data-level="dormant" style="--yc:#c2410c;">Dormienti <span class="badge bg-light text-dark" id="dCntDormant">0</span></button>
<button type="button" class="chip" data-level="lost" style="--yc:#b02a37;">Probabilmente persi <span class="badge bg-light text-dark" id="dCntLost">0</span></button>
<div class="col-12" data-widget="dormant" data-title="Clienti da richiamare">
<div class="chart-card" id="dormCard">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-2">
<div>
<div class="chart-title" data-help="yr.dormant"><i class="fa-solid fa-phone-volume me-2" style="color:#c2410c;"></i>Clienti da richiamare</div>
<div class="chart-hint" id="dormHint">&nbsp;</div>
</div>
<div class="chip-row" id="dormChips">
<button type="button" class="chip active" data-level="" style="--yc:#198754;">Tutti <span class="badge bg-light text-dark" id="dCntAll">0</span></button>
<button type="button" class="chip" data-level="watch" style="--yc:#ca8a04;">Attenzione <span class="badge bg-light text-dark" id="dCntWatch">0</span></button>
<button type="button" class="chip" data-level="dormant" style="--yc:#c2410c;">Dormienti <span class="badge bg-light text-dark" id="dCntDormant">0</span></button>
<button type="button" class="chip" data-level="lost" style="--yc:#b02a37;">Probabilmente persi <span class="badge bg-light text-dark" id="dCntLost">0</span></button>
</div>
</div>
<div class="dorm-sum mb-2" id="dormSum"></div>
<div class="table-responsive">
<table id="dormTable" class="table table-striped align-middle w-100"></table>
</div>
</div>
</div>
<div class="dorm-sum mb-2" id="dormSum"></div>
<div class="table-responsive">
<table id="dormTable" class="table table-striped align-middle w-100"></table>
</div>
</div>
<!-- CLIENTI -->
<div class="chart-card">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
<div>
<div class="chart-title" id="ttlCust" data-help="yr.custcmp">Clienti a confronto</div>
<div class="chart-hint" id="custHint">Clicca un cliente per filtrare tutta la pagina</div>
<div class="col-12" data-widget="customers" data-title="Clienti a confronto">
<div class="chart-card">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
<div>
<div class="chart-title" id="ttlCust" data-help="yr.custcmp">Clienti a confronto</div>
<div class="chart-hint" id="custHint">Clicca un cliente per filtrare tutta la pagina</div>
</div>
<div class="chip-row" id="trendChips">
<button type="button" class="chip active" data-trend="" style="--yc:#198754;">Tutti <span class="badge bg-light text-dark" id="cntAll">0</span></button>
<button type="button" class="chip" data-trend="new" style="--yc:#0369a1;">Nuovi <span class="badge bg-light text-dark" id="cntNew">0</span></button>
<button type="button" class="chip" data-trend="lost" style="--yc:#b02a37;">Persi <span class="badge bg-light text-dark" id="cntLost">0</span></button>
<button type="button" class="chip" data-trend="up" style="--yc:#15803d;">In crescita <span class="badge bg-light text-dark" id="cntUp">0</span></button>
<button type="button" class="chip" data-trend="down" style="--yc:#c2410c;">In calo <span class="badge bg-light text-dark" id="cntDown">0</span></button>
</div>
</div>
<div class="table-responsive">
<table id="custTable" class="table table-striped align-middle w-100"></table>
</div>
</div>
<div class="chip-row" id="trendChips">
<button type="button" class="chip active" data-trend="" style="--yc:#198754;">Tutti <span class="badge bg-light text-dark" id="cntAll">0</span></button>
<button type="button" class="chip" data-trend="new" style="--yc:#0369a1;">Nuovi <span class="badge bg-light text-dark" id="cntNew">0</span></button>
<button type="button" class="chip" data-trend="lost" style="--yc:#b02a37;">Persi <span class="badge bg-light text-dark" id="cntLost">0</span></button>
<button type="button" class="chip" data-trend="up" style="--yc:#15803d;">In crescita <span class="badge bg-light text-dark" id="cntUp">0</span></button>
<button type="button" class="chip" data-trend="down" style="--yc:#c2410c;">In calo <span class="badge bg-light text-dark" id="cntDown">0</span></button>
</div>
</div>
<div class="table-responsive">
<table id="custTable" class="table table-striped align-middle w-100"></table>
</div>
</div>
</div>
@@ -2104,7 +2104,7 @@ if ($action === 'data') {
// fatturato: sempre in € (indipendente dal selettore N° ordini / Valore)
function renderInvoiced(d) {
const inv = d.invoiced || {};
$('#invRow, #invTableCard').toggle(!!inv.available);
$('.inv-w, #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.' :
@@ -2388,7 +2388,7 @@ if ($action === 'data') {
});
})();
</script>
<script src="sales_help.js?v=2"></script>
<script src="sales_help.js?v=3"></script>
</body>
</html>
+14 -7
View File
@@ -404,7 +404,8 @@ if ($action === 'data') {
SUM(CASE WHEN x.order_date > CURDATE() - INTERVAL 30 DAY THEN x.taxable_amount ELSE 0 END) AS v30,
SUM(x.order_date <= CURDATE() - INTERVAL 30 DAY AND x.order_date > CURDATE() - INTERVAL 60 DAY) AS n30p,
SUM(CASE WHEN x.order_date <= CURDATE() - INTERVAL 30 DAY AND x.order_date > CURDATE() - INTERVAL 60 DAY THEN x.taxable_amount ELSE 0 END) AS v30p,
COUNT(DISTINCT CASE WHEN x.order_date > CURDATE() - INTERVAL 30 DAY THEN x.customer END) AS c30
COUNT(DISTINCT CASE WHEN x.order_date > CURDATE() - INTERVAL 30 DAY THEN x.customer END) AS c30,
CURDATE() AS d0, CURDATE() - INTERVAL 6 DAY AS d7, CURDATE() - INTERVAL 29 DAY AS d30
FROM $from
WHERE x.status <> 'annullato' AND x.order_date > CURDATE() - INTERVAL 60 DAY AND x.order_date <= CURDATE()
", [])[0];
@@ -416,7 +417,9 @@ if ($action === 'data') {
'v' => (float)$fl['v0'],
'np' => (int)$fl['n0p'],
'vp' => (float)$fl['v0p'],
'vs' => 'stesso giorno della settimana scorsa'
'vs' => 'stesso giorno della settimana scorsa',
'from' => (string)$fl['d0'],
'to' => (string)$fl['d0']
],
[
'key' => 'week',
@@ -425,7 +428,9 @@ if ($action === 'data') {
'v' => (float)$fl['v7'],
'np' => (int)$fl['n7p'],
'vp' => (float)$fl['v7p'],
'vs' => '7 giorni precedenti'
'vs' => '7 giorni precedenti',
'from' => (string)$fl['d7'],
'to' => (string)$fl['d0']
],
[
'key' => 'month',
@@ -435,7 +440,9 @@ if ($action === 'data') {
'np' => (int)$fl['n30p'],
'vp' => (float)$fl['v30p'],
'vs' => '30 giorni precedenti',
'customers' => (int)$fl['c30']
'customers' => (int)$fl['c30'],
'from' => (string)$fl['d30'],
'to' => (string)$fl['d0']
],
];
@@ -1868,7 +1875,7 @@ if ($action === 'data') {
<div class="legend-strip">
<span><span class="dot green"></span> Bene</span>
<span><span class="dot yellow"></span> Così così: da tenere d'occhio</span>
<span><span class="dot red"></span> Da Migliorare: serve un intervento</span>
<span><span class="dot red"></span> Male: serve un intervento</span>
<span><span class="dot grey"></span> Dato non disponibile</span>
<span class="ms-auto">Clicca una tessera per i dettagli · ⓘ per la spiegazione</span>
</div>
@@ -1902,7 +1909,7 @@ if ($action === 'data') {
const LIGHT_LABEL = {
green: 'Bene',
yellow: 'Così così',
red: 'Da Migliorare',
red: 'Male',
grey: 'n.d.'
};
const LIGHT_ICON = {
@@ -2025,7 +2032,7 @@ if ($action === 'data') {
month: 'fa-calendar'
};
$('#flow').html((d.flow || []).map(f => `
<a class="fl-card" href="sales_orders_stats.php" title="Apri Andamento ordini">
<a class="fl-card" href="sales_orders_stats.php?from=${encodeURIComponent(f.from || '')}&to=${encodeURIComponent(f.to || '')}&st=aperto,evaso,bloccato" title="Apri questi ordini in Andamento ordini">
<div class="fl-top"><span class="fl-t"><i class="fa-solid ${icons[f.key]} me-1"></i>${esc(f.title)}</span></div>
<div class="fl-row">
<div>
+361
View File
@@ -0,0 +1,361 @@
/*
* Riquadri personalizzabili per utente.
*
* <button data-layout-btn="pagina">…</button> (facoltativo: altrimenti il pulsante viene creato)
* <div class="row g-3" data-layout="pagina" data-prefs='{…}'>
* <div class="col-xl-6" data-widget="chiave" data-title="Nome leggibile">…</div>
* </div>
*
* In modalità "Personalizza": trascina dalla barra (dito o mouse) oppure usa ↑ ↓,
* cambia larghezza, nascondi/mostra. "Fatto" salva sul server per l'utente, "Predefinito" ripristina.
* Richiede SortableJS (caricato al volo se manca) e jQuery solo se c'è DataTables.
*/
(function () {
"use strict";
const SORTABLE_URL =
"https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js";
const SIZES = [null, 12, 8, 6, 4]; // null = larghezza originale
const SIZE_TXT = {
null: "Auto",
12: "Intera",
8: "2/3",
6: "Metà",
4: "1/3",
7: "7/12",
5: "5/12",
};
const LS = "ui_layout_";
function css() {
if (document.getElementById("ulCss")) return;
const s = document.createElement("style");
s.id = "ulCss";
s.textContent = `
.ul-btn{display:inline-flex;align-items:center;gap:6px;border:1px solid #cbd5e1;background:#fff;color:#334155;
border-radius:999px;padding:6px 14px;font-size:.85rem;font-weight:600;cursor:pointer;min-height:38px}
.ul-btn:hover{background:#f1f5f9}
.ul-btn.on{background:#0f766e;border-color:#0f766e;color:#fff}
.ul-btnbar{display:flex;justify-content:flex-end;margin-bottom:8px}
[data-layout].ul-edit>[data-widget]{position:relative}
[data-layout].ul-edit>[data-widget]>*:not(.ul-bar){pointer-events:none;opacity:.55;max-height:260px;overflow:hidden}
[data-layout].ul-edit>[data-widget].ul-hidden>*:not(.ul-bar){opacity:.18}
[data-layout]>[data-widget].ul-hidden{display:none}
[data-layout].ul-edit>[data-widget].ul-hidden{display:block}
.ul-bar{display:none}
[data-layout].ul-edit .ul-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;background:#0f766e;color:#fff;
border-radius:12px;padding:6px 8px;margin-bottom:6px;touch-action:none;cursor:grab;user-select:none;-webkit-user-select:none}
.ul-bar .ul-t{flex:1 1 auto;font-weight:700;font-size:.9rem;min-width:90px;display:flex;align-items:center;gap:8px}
.ul-bar button{border:0;background:rgba(255,255,255,.18);color:#fff;border-radius:9px;min-width:40px;height:40px;
padding:0 10px;font-size:.85rem;font-weight:600;cursor:pointer}
.ul-bar button:active{background:rgba(255,255,255,.35)}
.ul-hidden .ul-bar{background:#64748b}
.ul-ghost .ul-bar{background:#f59e0b}
.ul-ghost{opacity:.6}
.ul-dock{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:1080;display:flex;gap:8px;align-items:center;
background:#0f172a;color:#fff;border-radius:999px;padding:8px 10px 8px 18px;box-shadow:0 10px 30px rgba(0,0,0,.35);
font-size:.9rem;max-width:calc(100vw - 24px);flex-wrap:wrap;justify-content:center}
.ul-dock button{border:0;border-radius:999px;padding:0 16px;height:42px;font-weight:700;cursor:pointer}
.ul-dock .ok{background:#22c55e;color:#052e16}
.ul-dock .rs{background:#334155;color:#fff}
.ul-toast{position:fixed;right:18px;bottom:18px;z-index:1090;background:#0f172a;color:#fff;border-radius:12px;
padding:10px 16px;font-size:.9rem;box-shadow:0 8px 24px rgba(0,0,0,.3)}
`;
document.head.appendChild(s);
}
function loadSortable() {
if (window.Sortable) return Promise.resolve();
return new Promise((res) => {
const s = document.createElement("script");
s.src = SORTABLE_URL;
s.onload = res;
s.onerror = res;
document.head.appendChild(s);
});
}
function toast(msg) {
const t = document.createElement("div");
t.className = "ul-toast";
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 2200);
}
// grafici e tabelle si ridisegnano sulla nuova larghezza
function relayout() {
window.dispatchEvent(new Event("resize"));
try {
if (window.jQuery && jQuery.fn.dataTable)
jQuery.fn.dataTable
.tables({ visible: true, api: true })
.columns.adjust();
} catch (e) {}
}
function Layout(box) {
this.box = box;
this.page = box.dataset.layout;
this.widgets = Array.from(box.children).filter(
(el) => el.dataset.widget,
);
this.defOrder = this.widgets.map((w) => w.dataset.widget);
this.widgets.forEach((w) => {
w.dataset.ulClass = w.className;
});
let p = {};
try {
p = JSON.parse(box.dataset.prefs || "{}") || {};
} catch (e) {}
if (!p.order || !p.order.length) {
// server vuoto: eventuale copia locale
try {
p =
JSON.parse(
localStorage.getItem(LS + this.page) || "null",
) || p;
} catch (e) {}
}
this.state = {
order: Array.isArray(p.order) ? p.order : [],
hidden: Array.isArray(p.hidden) ? p.hidden : [],
sizes: p.sizes && typeof p.sizes === "object" ? p.sizes : {},
};
this.editing = false;
this.apply();
this.button();
}
Layout.prototype.byKey = function (k) {
return this.widgets.find((w) => w.dataset.widget === k);
};
Layout.prototype.apply = function () {
const st = this.state;
// ordine salvato; i riquadri nuovi (non presenti nel salvataggio) restano vicino al loro posto originale
const known = st.order.filter((k) => this.byKey(k));
const final = known.slice();
this.defOrder.forEach((k, i) => {
if (final.includes(k)) return;
const prev = this.defOrder
.slice(0, i)
.reverse()
.find((x) => final.includes(x));
final.splice(prev ? final.indexOf(prev) + 1 : 0, 0, k);
});
final.forEach((k) => this.box.appendChild(this.byKey(k)));
this.widgets.forEach((w) => {
const k = w.dataset.widget;
this.setSize(w, st.sizes[k] || null);
w.classList.toggle("ul-hidden", st.hidden.includes(k));
});
};
Layout.prototype.setSize = function (w, n) {
if (!n) {
w.className = w.dataset.ulClass;
} else {
const keep = w.dataset.ulClass
.split(/\s+/)
.filter(
(c) => c && !/^col(-(sm|md|lg|xl|xxl))?(-\d+)?$/.test(c),
);
w.className = keep
.concat(n === 12 ? ["col-12"] : ["col-12", "col-xl-" + n])
.join(" ");
}
if (
w.classList.contains("ul-hidden") !==
this.state.hidden.includes(w.dataset.widget)
)
w.classList.toggle("ul-hidden");
const b = w.querySelector(':scope > .ul-bar [data-ul="size"]');
if (b) b.textContent = "↔ " + SIZE_TXT[n || null];
};
Layout.prototype.read = function () {
this.state.order = Array.from(this.box.children)
.filter((el) => el.dataset.widget)
.map((el) => el.dataset.widget);
};
Layout.prototype.button = function () {
let b = document.querySelector(`[data-layout-btn="${this.page}"]`);
if (!b) {
const bar = document.createElement("div");
bar.className = "ul-btnbar";
b = document.createElement("button");
b.type = "button";
bar.appendChild(b);
this.box.parentNode.insertBefore(bar, this.box);
}
b.classList.add("ul-btn");
if (!b.innerHTML.trim())
b.innerHTML =
'<i class="fa-solid fa-table-cells-large"></i> Personalizza';
b.title = "Sposta, allarga o nascondi i riquadri di questa pagina";
this.btn = b;
b.addEventListener("click", () =>
this.editing ? this.done() : this.edit(),
);
};
Layout.prototype.edit = function () {
if (document.querySelector(".ul-dock")) return; // un solo contenitore alla volta
this.editing = true;
this.btn.classList.add("on");
this.box.classList.add("ul-edit");
this.widgets.forEach((w) => {
const k = w.dataset.widget;
const bar = document.createElement("div");
bar.className = "ul-bar";
bar.innerHTML = `<span class="ul-t"><i class="fa-solid fa-grip-vertical"></i>${(w.dataset.title || k).replace(/</g, "&lt;")}</span>
<button type="button" data-ul="up" title="Sposta su">↑</button>
<button type="button" data-ul="down" title="Sposta giù">↓</button>
<button type="button" data-ul="size" title="Larghezza">↔</button>
<button type="button" data-ul="eye" title="Mostra / nascondi"></button>`;
w.insertBefore(bar, w.firstChild);
this.setSize(w, this.state.sizes[k] || null);
this.eye(w);
bar.addEventListener("click", (e) => {
const a = e.target.closest("[data-ul]");
if (a) this.act(w, a.dataset.ul);
});
});
this.dock = document.createElement("div");
this.dock.className = "ul-dock";
this.dock.innerHTML = `<span>Trascina i riquadri o usa ↑ ↓</span>
<button type="button" class="rs">Predefinito</button><button type="button" class="ok">✓ Fatto</button>`;
document.body.appendChild(this.dock);
this.dock.querySelector(".ok").onclick = () => this.done();
this.dock.querySelector(".rs").onclick = () => this.reset();
loadSortable().then(() => {
if (!window.Sortable || !this.editing) return;
this.sortable = Sortable.create(this.box, {
draggable: "[data-widget]",
handle: ".ul-bar",
filter: "button",
preventOnFilter: false,
forceFallback: true,
fallbackTolerance: 4,
animation: 160,
ghostClass: "ul-ghost",
scroll: true,
bubbleScroll: true,
onEnd: () => this.read(),
});
});
relayout();
};
Layout.prototype.eye = function (w) {
const b = w.querySelector(':scope > .ul-bar [data-ul="eye"]');
if (b)
b.textContent = this.state.hidden.includes(w.dataset.widget)
? "🚫 Nascosto"
: "👁 Visibile";
};
Layout.prototype.act = function (w, a) {
const k = w.dataset.widget;
const st = this.state;
if (
a === "up" &&
w.previousElementSibling &&
w.previousElementSibling.dataset.widget
) {
this.box.insertBefore(w, w.previousElementSibling);
} else if (a === "down" && w.nextElementSibling) {
this.box.insertBefore(w.nextElementSibling, w);
} else if (a === "size") {
const cur = st.sizes[k] || null;
const n = SIZES[(SIZES.indexOf(cur) + 1) % SIZES.length];
if (n) st.sizes[k] = n;
else delete st.sizes[k];
this.setSize(w, n);
relayout();
} else if (a === "eye") {
st.hidden = st.hidden.includes(k)
? st.hidden.filter((x) => x !== k)
: st.hidden.concat(k);
w.classList.toggle("ul-hidden", st.hidden.includes(k));
this.eye(w);
}
this.read();
if (a === "up" || a === "down")
w.scrollIntoView({ block: "nearest", behavior: "smooth" });
};
Layout.prototype.stop = function () {
this.editing = false;
this.btn.classList.remove("on");
this.box.classList.remove("ul-edit");
this.box
.querySelectorAll(":scope > [data-widget] > .ul-bar")
.forEach((b) => b.remove());
if (this.sortable) {
this.sortable.destroy();
this.sortable = null;
}
if (this.dock) {
this.dock.remove();
this.dock = null;
}
setTimeout(relayout, 50);
};
Layout.prototype.post = function (data) {
const fd = new FormData();
Object.keys(data).forEach((k) => fd.append(k, data[k]));
fd.append("page", this.page);
return fetch("ui_prefs.php", {
method: "POST",
body: fd,
credentials: "same-origin",
})
.then((r) => r.json())
.catch(() => ({ ok: false }));
};
Layout.prototype.done = function () {
this.read();
const st = this.state;
this.stop();
try {
localStorage.setItem(LS + this.page, JSON.stringify(st));
} catch (e) {}
this.post({ action: "save", layout: JSON.stringify(st) }).then((r) => {
toast(
r && r.ok
? "Disposizione salvata"
: "Salvata solo su questo dispositivo",
);
});
};
Layout.prototype.reset = function () {
this.state = { order: [], hidden: [], sizes: {} };
this.apply();
this.stop();
try {
localStorage.removeItem(LS + this.page);
} catch (e) {}
this.post({ action: "reset" }).then(() =>
toast("Disposizione predefinita ripristinata"),
);
};
function init() {
css();
document.querySelectorAll("[data-layout]").forEach((box) => {
if (!box.__ul) box.__ul = new Layout(box);
});
}
if (document.readyState === "loading")
document.addEventListener("DOMContentLoaded", init);
else init();
})();
+45
View File
@@ -0,0 +1,45 @@
<?php
/**
* Salvataggio preferenze di layout per utente (usato da ui_layout.js).
* POST action=save page=... layout={json}
* POST action=reset page=...
*/
ob_start();
include('include/headscript.php');
require_once __DIR__ . '/class/UiPrefs.php';
while (ob_get_level()) ob_end_clean();
header('Content-Type: application/json; charset=utf-8');
$uid = (int)($iduserlogin ?? 0);
if ($uid <= 0 || $_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'message' => 'Sessione non valida']);
exit;
}
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$page = (string)($_POST['page'] ?? '');
$action = (string)($_POST['action'] ?? '');
try {
if (!UiPrefs::validPage($page)) throw new Exception('Pagina non valida');
if ($action === 'save') {
$raw = (string)($_POST['layout'] ?? '');
if (strlen($raw) > 8000) throw new Exception('Layout troppo grande');
$layout = json_decode($raw, true);
if (!is_array($layout)) throw new Exception('Layout non valido');
UiPrefs::save($pdo, $uid, $page, $layout);
echo json_encode(['ok' => true]);
} elseif ($action === 'reset') {
UiPrefs::reset($pdo, $uid, $page);
echo json_encode(['ok' => true]);
} else {
throw new Exception('Azione non valida');
}
} catch (Throwable $e) {
echo json_encode(['ok' => false, 'message' => $e->getMessage()]);
}