added prefernce business overview
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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"> </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"> </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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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, "<")}</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();
|
||||
})();
|
||||
@@ -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()]);
|
||||
}
|
||||
Reference in New Issue
Block a user