Files
zibo-dashboard/public/userarea/sales_preventivo.php
T
2026-10-27 12:23:09 +01:00

1385 lines
58 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
ob_start();
ini_set('display_errors', 1);
error_reporting(E_ALL);
include('include/headscript.php');
require_once __DIR__ . '/class/MagoSalesStats.php';
require_once __DIR__ . '/class/MagoQuote.php';
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
if (!userCan('sales.quote.view')) {
if (isset($_GET['action']) || isset($_POST['action'])) {
MagoSalesStats::jsonOut(['success' => false, 'message' => 'Non hai i permessi per questa pagina']);
}
while (ob_get_level()) ob_end_clean();
http_response_code(403);
echo '<div style="font-family:sans-serif;padding:60px;text-align:center;"><h3>Accesso non consentito</h3><p>Non hai i permessi per visualizzare questa pagina.</p><a href="production_dashboard.php">Torna alla Dashboard</a></div>';
exit;
}
$canDeliveries = userCan('sales.deliveries.view');
$canMatrici = userCan('sales.matrici.view');
/* ============================================================
* AJAX
* ============================================================ */
$action = $_GET['action'] ?? $_POST['action'] ?? '';
try {
if ($action === 'matrici') {
MagoSalesStats::jsonOut(['results' => MagoQuote::searchMatrici($pdo, (string)($_GET['q'] ?? ''))]);
}
if ($action === 'customers') {
$f = MagoSalesStats::filterOptions($pdo);
MagoSalesStats::jsonOut(['success' => true, 'customers' => $f['customers'] ?? []]);
}
if ($action === 'data') {
$c = trim((string)($_GET['c'] ?? ''));
MagoSalesStats::jsonOut(MagoQuote::data($pdo, (int)($_GET['m'] ?? 0), (string)($_GET['u'] ?? 'm'), $c !== '' ? $c : null));
}
if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') {
MagoSalesStats::jsonOut(MagoQuote::save($pdo, $_POST, (int)($iduserlogin ?? 0)));
}
if ($action === 'delete' && $_SERVER['REQUEST_METHOD'] === 'POST') {
MagoSalesStats::jsonOut(['success' => MagoQuote::delete($pdo, (int)($_POST['id'] ?? 0))]);
}
} catch (Throwable $e) {
MagoSalesStats::jsonOut(['success' => false, 'message' => $e->getMessage()]);
}
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
<?php include('cssinclude.php'); ?>
<title>Preventivo matrice - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.full.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/apexcharts@3.49.1"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<style>
:root {
--c-ink: #1f2d3d;
--c-muted: #64748b;
--c-line: #e2e8f0;
--pv: #0f766e;
}
body {
font-size: 1.02rem;
background: #f8fafc;
}
.card {
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, .08);
}
.back-dashboard {
background: #b9ebc7 !important;
color: var(--c-ink) !important;
border-radius: 10px;
font-weight: 600;
padding: 10px 18px;
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.stats-title {
display: flex;
align-items: center;
gap: 14px;
}
.stats-title-icon {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #0f766e, #22c55e);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
box-shadow: 0 6px 16px rgba(15, 118, 110, .28);
}
.stats-tabs {
display: flex;
gap: 6px;
border-bottom: 2px solid var(--c-line);
margin-bottom: 18px;
flex-wrap: wrap;
}
.stats-tabs a {
padding: 10px 18px;
border-radius: 12px 12px 0 0;
font-weight: 700;
color: var(--c-muted);
text-decoration: none;
background: #f1f5f9;
margin-bottom: -2px;
border: 2px solid transparent;
}
.stats-tabs a.active {
background: #fff;
color: #198754;
border-color: var(--c-line);
border-bottom-color: #fff;
}
.pv-box {
background: #fff;
border: 1px solid var(--c-line);
border-radius: 16px;
padding: 16px;
height: 100%;
}
.pv-title {
font-weight: 800;
color: var(--c-ink);
font-size: 1.02rem;
}
.pv-hint {
font-size: .82rem;
color: var(--c-muted);
}
.flabel {
font-weight: 700;
font-size: .8rem;
color: var(--c-muted);
text-transform: uppercase;
letter-spacing: .4px;
margin-bottom: 4px;
display: block;
}
.select2-container--bootstrap-5 .select2-selection {
border-radius: 10px;
min-height: 46px;
}
.seg {
display: inline-flex;
background: #f1f5f9;
border-radius: 12px;
padding: 4px;
gap: 4px;
}
.seg button {
border: 0;
background: transparent;
padding: 9px 18px;
border-radius: 9px;
font-weight: 700;
color: var(--c-muted);
}
.seg button.active {
background: #fff;
color: var(--c-ink);
box-shadow: 0 2px 6px rgba(15, 23, 42, .1);
}
.mx-opt {
display: flex;
align-items: center;
gap: 10px;
}
.mx-opt img,
.mx-opt .ph {
width: 34px;
height: 34px;
border-radius: 8px;
object-fit: cover;
background: #f1f5f9;
flex: 0 0 auto;
}
.pv-head {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 12px;
}
.pv-photo {
width: 74px;
height: 74px;
border-radius: 14px;
object-fit: cover;
border: 1px solid var(--c-line);
background: #f8fafc;
}
.pv-name {
font-size: 1.3rem;
font-weight: 800;
color: var(--c-ink);
}
.tb {
display: inline-block;
font-size: .7rem;
font-weight: 800;
border-radius: 999px;
padding: 2px 8px;
white-space: nowrap;
}
.tb-warn {
background: #fee2e2;
color: #991b1b;
}
.tb-old {
background: #fef3c7;
color: #92400e;
}
.tb-check {
background: #ffedd5;
color: #9a3412;
}
.tb-ok {
background: #dcfce7;
color: #166534;
}
.pv-mix th {
font-size: .78rem;
color: var(--c-muted);
text-transform: uppercase;
white-space: nowrap;
}
.pv-mix input {
width: 92px;
text-align: right;
border-radius: 8px;
padding: 4px 8px;
border: 1px solid #cbd5e1;
min-height: 38px;
}
.pv-mix input.missing {
border-color: #dc2626;
background: #fef2f2;
}
.pv-mix input.edited {
background: #fffbeb;
border-color: #f59e0b;
}
.num {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.pv-sum {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-radius: 12px;
background: #f8fafc;
margin-top: 8px;
}
.pv-sum.total {
background: #fef2f2;
border: 1px solid #fecaca;
}
.pv-sum b {
font-size: 1.15rem;
}
.pv-in {
display: flex;
align-items: center;
gap: 6px;
}
.pv-in input {
max-width: 130px;
text-align: right;
font-weight: 700;
border-radius: 10px;
min-height: 44px;
}
.pv-big {
font-size: 2.3rem;
font-weight: 900;
color: var(--pv);
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.chip-mk {
border: 1.5px solid var(--c-line);
background: #fff;
border-radius: 999px;
padding: 7px 13px;
font-weight: 700;
font-size: .88rem;
min-height: 40px;
}
.chip-mk.active {
background: var(--pv);
border-color: var(--pv);
color: #fff;
}
.ref-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.ref {
border: 1px solid var(--c-line);
border-radius: 12px;
padding: 10px 12px;
background: #fff;
}
.ref .l {
font-size: .74rem;
font-weight: 800;
text-transform: uppercase;
color: var(--c-muted);
letter-spacing: .3px;
}
.ref .v {
font-size: 1.15rem;
font-weight: 800;
color: var(--c-ink);
font-variant-numeric: tabular-nums;
}
.ref .s {
font-size: .8rem;
color: var(--c-muted);
}
.dp {
font-weight: 800;
}
.dp.up {
color: #15803d;
}
.dp.down {
color: #b91c1c;
}
.mg {
display: inline-block;
font-weight: 800;
border-radius: 999px;
padding: 3px 11px;
}
.mg-hi {
background: #dcfce7;
color: #166534;
}
.mg-mid {
background: #fef9c3;
color: #854d0e;
}
.mg-lo {
background: #fee2e2;
color: #991b1b;
}
.empty-state {
text-align: center;
padding: 50px 20px;
color: var(--c-muted);
}
.q-table td,
.q-table th {
vertical-align: middle;
}
.q-table thead th {
background: #ccfbf1;
white-space: nowrap;
}
.btn-pv {
background: var(--pv);
color: #fff;
border-radius: 10px;
font-weight: 700;
padding: 10px 18px;
}
.btn-pv:hover {
background: #115e59;
color: #fff;
}
@media print {
.no-print,
.sidebar-wrapper,
.topbar,
.page-footer,
.stats-tabs,
#pvPick,
#pvHistory {
display: none !important;
}
.page-wrapper {
margin: 0 !important;
}
body {
background: #fff;
}
.card {
box-shadow: none;
}
}
</style>
</head>
<body>
<div class="wrapper toggled" id="appWrapper">
<?php include('include/navbar.php'); ?>
<?php include('include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<div class="card p-3">
<div class="card-header bg-white border-0 pb-3">
<div class="stats-header">
<div class="stats-title">
<div class="stats-title-icon"><i class="fa-solid fa-calculator"></i></div>
<div>
<h5 class="mb-1" style="font-weight:800;">Preventivo matrice</h5>
<div class="pv-hint">Costo della mescola, prezzi praticati e prezzo suggerito</div>
</div>
</div>
<button type="button" class="btn back-dashboard no-print" onclick="location.href='production_dashboard.php'">↩️ Torna alla Dashboard</button>
</div>
</div>
<div class="card-body pt-0">
<div class="stats-tabs">
<a href="sales_summary.php"><i class="fa-solid fa-gauge-high me-1"></i> Sommario</a>
<a href="sales_orders_stats.php"><i class="fa-solid fa-list-check me-1"></i> Andamento ordini</a>
<a href="sales_orders_years.php"><i class="fa-solid fa-chart-column me-1"></i> Confronto anni</a>
<a href="sales_customer.php"><i class="fa-solid fa-address-card me-1"></i> Scheda cliente</a>
<?php if ($canMatrici) : ?>
<a href="sales_matrici.php"><i class="fa-solid fa-shapes me-1"></i> Matrici</a>
<?php endif; ?>
<a href="sales_preventivo.php" class="active"><i class="fa-solid fa-calculator me-1"></i> Preventivo</a>
<?php if ($canDeliveries) : ?>
<a href="sales_orders_deliveries.php"><i class="fa-solid fa-truck-fast me-1"></i> Consegne</a>
<?php endif; ?>
<button type="button" class="sh-page ms-auto align-self-center mb-1" data-help-open="page.quote"><i class="fa-regular fa-circle-question me-1"></i> Come si usa</button>
</div>
<!-- SCELTA -->
<div class="pv-box mb-3" id="pvPick" style="height:auto;">
<div class="row g-3 align-items-end">
<div class="col-lg-5">
<label class="flabel" for="pvMatrice">Matrice</label>
<select id="pvMatrice" style="width:100%;"></select>
</div>
<div class="col-lg-4">
<label class="flabel" for="pvCustomer">Cliente <span class="text-muted text-lowercase fw-normal">(facoltativo)</span></label>
<select id="pvCustomer" style="width:100%;"></select>
</div>
<div class="col-lg-3">
<label class="flabel">Unità di vendita</label>
<div class="seg" id="pvUom">
<button type="button" data-u="m" class="active">€ al metro</button>
<button type="button" data-u="kg">€ al kg</button>
</div>
</div>
</div>
</div>
<div id="pvEmpty" class="empty-state">
<i class="fa-solid fa-calculator fa-2x mb-2"></i>
<div>Scegli una matrice per calcolare il preventivo.</div>
</div>
<div id="pvLoading" class="empty-state" style="display:none;">
<div class="spinner-border text-secondary"></div>
</div>
<div id="pvContent" style="display:none;">
<div class="row g-3 mb-3">
<!-- COSTO -->
<div class="col-xl-5">
<div class="pv-box">
<div class="pv-head" id="pvHead"></div>
<div class="pv-title" data-help="pv.cost">1 · Costo</div>
<div class="pv-hint mb-2" id="pvCostHint"></div>
<div class="table-responsive">
<table class="table table-sm align-middle pv-mix mb-1">
<thead>
<tr>
<th>Mescola</th>
<th class="text-end">g/m</th>
<th class="text-end">€/kg</th>
<th class="text-end" id="pvPartTh">€/m</th>
</tr>
</thead>
<tbody id="pvMixBody"></tbody>
</table>
</div>
<div id="pvWarn"></div>
<div class="pv-sum"><span>Costo mescola</span><b class="num" id="pvCostMix">—</b></div>
<div class="row g-2 mt-1">
<div class="col-6">
<label class="flabel" for="pvScrap" data-help="pv.scrap">Scarto %</label>
<div class="pv-in"><input type="number" step="0.5" min="0" class="form-control" id="pvScrap" value="0"><span>%</span></div>
</div>
<div class="col-6">
<label class="flabel" for="pvOther" data-help="pv.other">Altri costi</label>
<div class="pv-in"><input type="number" step="0.001" min="0" class="form-control" id="pvOther" value="0"><span class="pv-ul">€/m</span></div>
</div>
</div>
<div class="pv-sum total"><span>Costo totale</span><b class="num" id="pvCostTot">—</b></div>
</div>
</div>
<!-- PREZZO -->
<div class="col-xl-7">
<div class="pv-box">
<div class="pv-title" data-help="pv.price">2 · Prezzo</div>
<div class="row g-3 mt-0 align-items-end">
<div class="col-md-5">
<label class="flabel" for="pvMarkup">Ricarico sul costo</label>
<div class="pv-in"><input type="number" step="1" class="form-control form-control-lg" id="pvMarkup" value="100"><span class="fw-bold">%</span></div>
</div>
<div class="col-md-7">
<div class="chips" id="pvChips"></div>
</div>
</div>
<div class="row g-3 mt-1 align-items-center">
<div class="col-md-6">
<div class="flabel">Prezzo suggerito</div>
<div class="pv-big" id="pvSuggested">—</div>
<div class="pv-hint" id="pvSuggestedHint"></div>
</div>
<div class="col-md-6">
<label class="flabel" for="pvOffered" data-help="pv.offered">Prezzo proposto</label>
<div class="pv-in"><input type="number" step="0.001" min="0" class="form-control form-control-lg" id="pvOffered"><span class="pv-ul">€/m</span></div>
<div class="mt-1" id="pvOfferedInfo"></div>
</div>
</div>
<hr>
<div class="flabel mb-2" data-help="pv.refs">Confronto con i prezzi praticati</div>
<div class="ref-grid" id="pvRefs"></div>
<div class="mt-3">
<label class="flabel" for="pvComment">Commento</label>
<textarea class="form-control" id="pvComment" rows="2" placeholder="es. quantità annua 20.000 m, consegna mensile, mescola alternativa…"></textarea>
</div>
<div class="d-flex gap-2 mt-3 justify-content-end no-print flex-wrap">
<button type="button" class="btn btn-outline-secondary" id="pvPrint"><i class="fa-solid fa-print me-1"></i> Stampa</button>
<button type="button" class="btn btn-pv" id="pvSave"><i class="fa-solid fa-floppy-disk me-1"></i> Salva preventivo</button>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-xl-8">
<div class="pv-box">
<div class="pv-title" data-help="pv.chart">Costo e prezzi negli ultimi 24 mesi</div>
<div class="pv-hint">Linea rossa: costo mescola del mese · grigia: prezzo medio di tutti i clienti · viola: prezzo al cliente scelto · verde: prezzo proposto</div>
<div id="pvChart" style="min-height:330px;"></div>
</div>
</div>
<div class="col-xl-4">
<div class="pv-box">
<div class="pv-title">Prezzi agli altri clienti (12 mesi)</div>
<div class="pv-hint mb-2">Prezzo medio per cliente, i più grandi per quantità</div>
<div id="pvClients"></div>
</div>
</div>
</div>
<div class="pv-box" id="pvHistory" style="height:auto;">
<div class="pv-title">Preventivi salvati per questa matrice</div>
<div class="pv-hint mb-2">Clic su <b>Riapri</b> per ricaricare ricarico, costi e commento.</div>
<div class="table-responsive">
<table class="table table-striped q-table mb-0">
<thead>
<tr>
<th>Data</th>
<th>Cliente</th>
<th class="text-end">Costo</th>
<th class="text-end">Ricarico</th>
<th class="text-end">Prezzo</th>
<th>Commento</th>
<th>Utente</th>
<th class="no-print"></th>
</tr>
</thead>
<tbody id="pvQuotes"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
<?php include('jsinclude.php'); ?>
<script>
(function() {
const MONTHS = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'];
const MARKUPS = [50, 80, 100, 150, 200];
let data = null;
let chart = null;
let state = {
m: null,
c: '',
u: 'm'
};
let offeredTouched = false;
let markupTouched = false;
/* ---------- utilità ---------- */
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
return {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
} [c];
});
}
function num(v, d) {
return (+v || 0).toLocaleString('it-IT', {
minimumFractionDigits: d || 0,
maximumFractionDigits: d || 0
});
}
function eur(v) {
if (v === null || v === undefined || !isFinite(v)) return '—';
return num(v, Math.abs(v) >= 10 ? 2 : 3) + ' €';
}
function val(id) {
const v = parseFloat(String($(id).val()).replace(',', '.'));
return isFinite(v) ? v : null;
}
function fdate(s) {
if (!s) return '';
const p = String(s).substring(0, 10).split('-');
return p[2] + '/' + p[1] + '/' + p[0];
}
function ul() {
return state.u === 'kg' ? 'kg' : 'm';
}
function marginPill(p) {
if (p === null || !isFinite(p)) return '';
const c = p >= 60 ? 'mg-hi' : p >= 40 ? 'mg-mid' : 'mg-lo';
return '<span class="mg ' + c + '">margine ' + num(p, 0) + '%</span>';
}
function delta(a, b) {
if (!a || !b) return '';
const d = (a - b) / b * 100;
const cls = d >= 0 ? 'up' : 'down';
return '<span class="dp ' + cls + '">' + (d >= 0 ? '+' : '') + num(d, 1) + '%</span>';
}
/* ---------- URL ---------- */
function readUrl() {
const q = new URLSearchParams(location.search);
if (q.get('m')) state.m = parseInt(q.get('m'), 10) || null;
if (q.get('c')) state.c = q.get('c');
if (q.get('u') === 'kg') state.u = 'kg';
}
function writeUrl() {
const u = new URL(location.href);
['m', 'c', 'u'].forEach(function(k) {
if (state[k]) u.searchParams.set(k, state[k]);
else u.searchParams.delete(k);
});
history.replaceState(null, '', u.toString());
}
/* ---------- caricamento ---------- */
function load() {
writeUrl();
$('#pvUom button').removeClass('active').filter('[data-u="' + state.u + '"]').addClass('active');
$('.pv-ul').text('€/' + ul());
$('#pvPartTh').text('€/' + ul());
if (!state.m) {
$('#pvContent, #pvLoading').hide();
$('#pvEmpty').show();
return;
}
$('#pvEmpty').hide();
$('#pvLoading').show();
const qs = new URLSearchParams({
action: 'data',
m: state.m,
c: state.c || '',
u: state.u
});
fetch('?' + qs).then(function(r) {
return r.json();
}).then(function(d) {
$('#pvLoading').hide();
if (!d.success) {
$('#pvEmpty').show().html('<div class="text-danger">' + esc(d.message || 'Errore') + '</div>');
return;
}
data = d;
offeredTouched = false;
$('#pvContent').show();
renderHead();
renderMix();
renderChips();
renderClients();
renderQuotes();
calc();
}).catch(function(e) {
$('#pvLoading').hide();
$('#pvEmpty').show().html('<div class="text-danger">Errore nel caricamento: ' + esc(e && e.message ? e.message : e) + '</div>');
});
}
function renderHead() {
const m = data.matrice;
// nome della matrice nella tendina (arrivando dall'URL o dalla pagina Matrici)
const $sel = $('#pvMatrice');
if (String($sel.val() || '') !== String(m.id) || !$sel.find('option:selected').text() || $sel.find('option:selected').text() !== m.name) {
$sel.find('option[value="' + m.id + '"]').remove();
$sel.append(new Option(m.name, m.id, true, true)).trigger('change.select2');
}
const photo = m.photo ? '<img class="pv-photo" src="' + esc(m.photo) + '" onerror="this.style.display=\'none\'">' : '';
let ws = '<span class="tb tb-warn">nessuna scheda di lavoro</span>';
if (m.ws) ws = 'Scheda di lavoro n° ' + esc(m.ws.number) + (m.ws.rev ? ' rev. ' + esc(m.ws.rev) : '') + (m.ws.date ? ' del ' + fdate(m.ws.date) : '');
const cust = data.customer ? '<div class="pv-hint">per <b>' + esc(data.customer.name) + '</b></div>' : '';
$('#pvHead').html(photo + '<div><div class="pv-name">' + esc(m.name) + ' <span class="tb tb-ok">' + esc(data.uom) + '</span></div><div class="pv-hint">' + ws + '</div>' + cust + '</div>');
const rule = data.kind === 'm' ? 'g/m × €/kg ÷ 1000' : 'media dei €/kg pesata sui g/m';
$('#pvCostHint').html('Costo = ' + rule + '. €/kg = media pagata in Mago negli ultimi ' + data.price_months + ' mesi. <b>Puoi correggere g/m ed €/kg</b> per questo preventivo.');
}
function renderMix() {
if (!data.mix.length) {
$('#pvMixBody').html('<tr><td colspan="4" class="text-muted">Nessuna mescola collegata alla matrice: compila la scheda di lavoro, oppure usa "Altri costi".</td></tr>');
$('#pvWarn').html('');
return;
}
let html = '';
data.mix.forEach(function(x, i) {
let info = '';
if (x.eur_kg === null) info += ' <span class="tb tb-warn">senza prezzo</span>';
else {
if (x.old) info += ' <span class="tb tb-old" title="Ultimo acquisto ' + fdate(x.last_date) + '">prezzo vecchio</span>';
if (x.check) info += ' <span class="tb tb-check" title="Prezzo da verificare (conto lavoro?)">' + esc(x.check) + ' da verificare</span>';
}
const sup = x.supplier ? '<div class="pv-hint">' + esc(x.supplier) + (x.last_date ? ' · ultimo ' + fdate(x.last_date) : '') + '</div>' : '';
html += '<tr data-i="' + i + '">' +
'<td><b>' + esc(x.name) + '</b>' + info + sup + '</td>' +
'<td class="text-end"><input type="number" step="0.1" min="0" class="pv-gm" value="' + (x.gm === null ? '' : x.gm) + '" placeholder="g/m"></td>' +
'<td class="text-end"><input type="number" step="0.001" min="0" class="pv-eur" value="' + (x.eur_kg === null ? '' : x.eur_kg) + '" placeholder="€/kg"></td>' +
'<td class="text-end num pv-part">—</td>' +
'</tr>';
});
$('#pvMixBody').html(html);
}
function readMix() {
const rows = [];
$('#pvMixBody tr[data-i]').each(function() {
const i = +$(this).attr('data-i');
const x = data.mix[i];
const gm = parseFloat($(this).find('.pv-gm').val());
const e = parseFloat($(this).find('.pv-eur').val());
const g = isFinite(gm) && gm > 0 ? gm : null;
const p = isFinite(e) && e > 0 ? e : null;
rows.push({
id: x.id,
name: x.name,
gm: g,
eur_kg: p,
edited: g !== x.gm || p !== x.eur_kg,
$tr: $(this)
});
});
return rows;
}
/* ---------- calcolo ---------- */
function calc() {
if (!data) return;
const rows = readMix();
const gmTot = rows.reduce(function(a, r) {
return a + (r.gm || 0);
}, 0);
let cost = 0,
priced = 0,
missing = 0;
rows.forEach(function(r) {
let part = null;
if (data.kind === 'm') {
if (r.gm !== null && r.eur_kg !== null) part = r.gm / 1000 * r.eur_kg;
} else if (r.eur_kg !== null) {
part = (gmTot > 0 ? (r.gm || 0) / gmTot : 1 / rows.length) * r.eur_kg;
}
if (part === null) missing++;
else {
cost += part;
priced++;
}
r.$tr.find('.pv-part').html(part === null ? '<span class="text-danger">—</span>' : eur(part));
r.$tr.find('.pv-gm').toggleClass('edited', r.gm !== data.mix[+r.$tr.attr('data-i')].gm)
.toggleClass('missing', r.gm === null && (data.kind === 'm'));
r.$tr.find('.pv-eur').toggleClass('edited', r.eur_kg !== data.mix[+r.$tr.attr('data-i')].eur_kg)
.toggleClass('missing', r.eur_kg === null);
});
const costMix = priced ? cost : null;
const scrap = val('#pvScrap') || 0;
const other = val('#pvOther') || 0;
const costTot = costMix !== null ? costMix * (1 + scrap / 100) + other : (other || null);
const markup = val('#pvMarkup');
const suggested = (costTot !== null && markup !== null) ? costTot * (1 + markup / 100) : null;
let warn = '';
if (missing && rows.length) warn += '<div class="alert alert-warning py-2 mb-1"><b>' + missing + ' mescol' + (missing === 1 ? 'a' : 'e') + ' senza g/m o €/kg</b>: il costo è incompleto. Compila i campi in rosso.</div>';
const checks = data.mix.filter(function(x) {
return x.check;
});
if (checks.length) warn += '<div class="alert alert-warning py-2 mb-1"><b>Prezzo da verificare</b> per ' + checks.map(function(x) {
return esc(x.name);
}).join(', ') + ': il prezzo pagato potrebbe non comprendere le materie prime (conto lavoro).</div>';
$('#pvWarn').html(warn);
$('#pvCostMix').text(costMix === null ? '—' : eur(costMix) + '/' + ul());
$('#pvCostTot').text(costTot === null ? '—' : eur(costTot) + '/' + ul());
$('#pvSuggested').html(suggested === null ? '—' : eur(suggested) + '<small class="fs-6 text-muted">/' + ul() + '</small>');
$('#pvSuggestedHint').text(suggested === null ? 'Serve il costo: compila g/m ed €/kg' : 'costo totale × (1 + ' + num(markup, 0) + '%)');
$('#pvChips .chip-mk').each(function() {
$(this).toggleClass('active', +$(this).attr('data-v') === markup);
});
if (!offeredTouched) $('#pvOffered').val(suggested === null ? '' : suggested.toFixed(3));
const offered = val('#pvOffered');
const mg = (offered && costTot) ? (offered - costTot) / offered * 100 : null;
$('#pvOfferedInfo').html((offered ? marginPill(mg) : '') + (offeredTouched && suggested ? ' <a href="#" id="pvResetOffered" class="small ms-2">torna al suggerito</a>' : ''));
renderRefs(offered);
renderChart(costTot, offered);
data._calc = {
costMix: costMix,
costTot: costTot,
suggested: suggested,
offered: offered,
rows: rows
};
}
function renderChips() {
let html = MARKUPS.map(function(v) {
return '<button type="button" class="chip-mk" data-v="' + v + '">+' + v + '%</button>';
}).join('');
if (data.all.markup !== null && data.all.markup > 0) {
html += '<button type="button" class="chip-mk" data-v="' + data.all.markup + '" title="Ricarico che si ottiene oggi con il prezzo medio di mercato">come mercato +' + num(data.all.markup, 0) + '%</button>';
}
$('#pvChips').html(html);
// ricarico iniziale: quello di mercato se c'è, altrimenti 100% (se l'utente l'ha già scelto lo tengo)
if (!markupTouched) $('#pvMarkup').val(data.all.markup !== null && data.all.markup > 0 ? data.all.markup : 100);
}
function refBox(label, v, sub, offered) {
const parts = [];
if (v && offered) parts.push('proposto ' + delta(offered, v));
if (sub) parts.push(sub);
return '<div class="ref"><div class="l">' + label + '</div><div class="v">' + (v ? eur(v) : '—') + '</div><div class="s">' + parts.join(' · ') + '</div></div>';
}
function renderRefs(offered) {
const c = data.customer;
const a = data.all;
let html = '';
if (c) {
html += refBox('Ultimo prezzo al cliente', c.last_price, c.last_date ? 'del ' + fdate(c.last_date) : 'mai venduta a questo cliente', offered);
html += refBox('Media cliente 12 mesi', c.avg12, '', offered);
}
html += refBox('Media mercato 12 mesi', a.avg12, a.n_clients ? a.n_clients + ' clienti' : 'nessuna vendita', offered);
html += '<div class="ref"><div class="l">Forbice 12 mesi</div><div class="v">' + (a.min12 ? eur(a.min12) + ' – ' + eur(a.max12) : '—') + '</div><div class="s">prezzo minimo e massimo</div></div>';
$('#pvRefs').html(html);
}
function renderClients() {
const list = data.all.clients || [];
if (!list.length) {
$('#pvClients').html('<div class="text-muted small">Nessuna vendita negli ultimi 12 mesi in questa unità.</div>');
return;
}
const max = Math.max.apply(null, list.map(function(x) {
return x.price;
}));
$('#pvClients').html(list.map(function(x) {
const me = data.customer && x.code === data.customer.code;
return '<div class="d-flex justify-content-between align-items-center py-1 border-bottom' + (me ? ' fw-bold' : '') + '">' +
'<div style="min-width:0;"><div class="text-truncate" style="max-width:220px;">' + (me ? '▶ ' : '') + esc(x.name) + '</div><div class="pv-hint">' + num(x.qty) + ' ' + esc(data.uom) + '</div></div>' +
'<div class="text-end num"><b>' + eur(x.price) + '</b><div style="height:5px;width:90px;background:#eef2f7;border-radius:9px;margin-top:3px;"><div style="height:5px;border-radius:9px;background:#0f766e;width:' + (x.price / max * 100) + '%"></div></div></div></div>';
}).join(''));
}
function renderChart(costTot, offered) {
const ms = data.months;
const pt = function(map) {
return ms.map(function(ym) {
return {
x: new Date(ym + '-15T12:00:00').getTime(),
y: map && map[ym] !== undefined ? map[ym] : null
};
});
};
const series = [{
name: 'Costo mescola',
type: 'line',
data: pt(data.cost_months)
}, {
name: 'Media tutti i clienti',
type: 'line',
data: pt(data.all.months)
}];
if (data.customer) series.push({
name: data.customer.name,
type: 'line',
data: pt(data.customer.months)
});
const ann = [];
if (offered) ann.push({
y: offered,
borderColor: '#16a34a',
strokeDashArray: 0,
label: {
text: 'Proposto ' + eur(offered),
style: {
background: '#16a34a',
color: '#fff'
}
}
});
if (costTot) ann.push({
y: costTot,
borderColor: '#dc2626',
strokeDashArray: 4,
label: {
text: 'Costo totale ' + eur(costTot),
position: 'left',
textAnchor: 'start',
style: {
background: '#dc2626',
color: '#fff'
}
}
});
const opts = {
chart: {
type: 'line',
height: 330,
fontFamily: 'inherit',
toolbar: {
show: false
},
zoom: {
enabled: false
},
animations: {
enabled: false
}
},
series: series,
colors: ['#dc2626', '#475569', '#7c3aed'],
stroke: {
width: [2.5, 2.5, 3],
curve: ['stepline', 'straight', 'straight'],
dashArray: [6, 0, 0]
},
markers: {
size: [0, 3, 5]
},
xaxis: {
type: 'datetime',
labels: {
datetimeUTC: false,
format: 'MMM yy'
}
},
yaxis: {
labels: {
formatter: function(v) {
return eur(v);
}
},
title: {
text: '€/' + ul()
}
},
annotations: {
yaxis: ann
},
legend: {
position: 'top',
horizontalAlign: 'left'
},
grid: {
borderColor: '#eef2f7'
},
tooltip: {
shared: true,
x: {
format: 'MMM yyyy'
},
y: {
formatter: function(v) {
return v === null ? '—' : eur(v);
}
}
},
noData: {
text: 'Nessun dato'
}
};
if (chart) chart.destroy();
chart = new ApexCharts(document.getElementById('pvChart'), opts);
chart.render();
}
function renderQuotes() {
const q = data.quotes || [];
if (!q.length) {
$('#pvQuotes').html('<tr><td colspan="8" class="text-muted">Nessun preventivo salvato per questa matrice.</td></tr>');
return;
}
$('#pvQuotes').html(q.map(function(x, i) {
const u = x.uom === 'KG' ? 'kg' : 'm';
return '<tr>' +
'<td class="num">' + fdate(x.created_at) + ' ' + String(x.created_at).substring(11, 16) + '</td>' +
'<td>' + esc(x.customer_name || '—') + '</td>' +
'<td class="text-end num">' + eur(+x.cost_total) + '/' + u + '</td>' +
'<td class="text-end num">' + (x.markup_pct === null ? '—' : num(+x.markup_pct, 0) + '%') + '</td>' +
'<td class="text-end num"><b>' + eur(+x.price_offered) + '</b>/' + u + '</td>' +
'<td style="max-width:320px;">' + esc(x.comment || '') + '</td>' +
'<td>' + esc(x.user_name || x.username || '') + '</td>' +
'<td class="text-nowrap no-print"><button type="button" class="btn btn-sm btn-outline-secondary pv-reopen" data-i="' + i + '">Riapri</button> ' +
'<button type="button" class="btn btn-sm btn-outline-danger pv-del" data-id="' + x.id + '" title="Elimina">🗑️</button></td>' +
'</tr>';
}).join(''));
}
function reopen(x) {
const go = function() {
$('#pvScrap').val(+x.scrap_pct || 0);
$('#pvOther').val(+x.other_costs || 0);
if (x.markup_pct !== null) {
markupTouched = true;
$('#pvMarkup').val(+x.markup_pct);
}
$('#pvComment').val(x.comment || '');
let mix = [];
try {
mix = JSON.parse(x.mix_detail || '[]');
} catch (e) {}
$('#pvMixBody tr[data-i]').each(function() {
const m = data.mix[+$(this).attr('data-i')];
const s = mix.find(function(y) {
return y.id === m.id;
});
if (s && s.edited) {
$(this).find('.pv-gm').val(s.gm === null ? '' : s.gm);
$(this).find('.pv-eur').val(s.eur_kg === null ? '' : s.eur_kg);
}
});
offeredTouched = true;
$('#pvOffered').val((+x.price_offered).toFixed(3));
calc();
window.scrollTo({
top: 0,
behavior: 'smooth'
});
};
const kind = x.uom === 'KG' ? 'kg' : 'm';
if (kind !== state.u || (x.customer_code || '') !== (state.c || '')) {
state.u = kind;
state.c = x.customer_code || '';
setCustomer(state.c, x.customer_name);
load();
const t = setInterval(function() {
if (data && data.uom === x.uom) {
clearInterval(t);
go();
}
}, 150);
setTimeout(function() {
clearInterval(t);
}, 8000);
} else go();
}
function save() {
const c = data && data._calc;
if (!c || !c.offered) {
Swal.fire({
icon: 'warning',
title: 'Manca il prezzo',
text: 'Compila il costo e il ricarico, oppure il prezzo proposto.'
});
return;
}
const body = new URLSearchParams({
action: 'save',
idmatrice: state.m,
kind: state.u,
customer: state.c || '',
scrap_pct: val('#pvScrap') || 0,
other_costs: val('#pvOther') || 0,
markup_pct: val('#pvMarkup') === null ? '' : val('#pvMarkup'),
price_offered: c.offered,
ref_last_price: data.customer && data.customer.last_price ? data.customer.last_price : '',
ref_avg_price: data.all.avg12 || '',
comment: $('#pvComment').val(),
mix: JSON.stringify(c.rows.map(function(r) {
return {
id: r.id,
name: r.name,
gm: r.gm,
eur_kg: r.eur_kg,
edited: r.edited
};
}))
});
fetch('sales_preventivo.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString()
})
.then(function(r) {
return r.json();
}).then(function(d) {
if (!d.success) {
Swal.fire({
icon: 'error',
title: 'Non salvato',
text: d.message || 'Errore'
});
return;
}
Swal.fire({
icon: 'success',
title: 'Preventivo salvato',
timer: 1300,
showConfirmButton: false
});
const qs = new URLSearchParams({
action: 'data',
m: state.m,
c: state.c || '',
u: state.u
});
fetch('?' + qs).then(function(r) {
return r.json();
}).then(function(dd) {
if (dd.success) {
data.quotes = dd.quotes;
renderQuotes();
}
});
});
}
function setCustomer(code, name) {
const $s = $('#pvCustomer');
if (code && !$s.find('option[value="' + code + '"]').length) $s.append(new Option(name || code, code, true, true));
$s.val(code || null).trigger('change.select2');
}
/* ---------- eventi ---------- */
$(function() {
readUrl();
$('#pvMatrice').select2({
theme: 'bootstrap-5',
placeholder: 'Cerca la matrice (es. 00786S)…',
allowClear: true,
width: '100%',
minimumInputLength: 0,
ajax: {
url: 'sales_preventivo.php',
delay: 250,
data: function(p) {
return {
action: 'matrici',
q: p.term || ''
};
},
processResults: function(d) {
return {
results: d.results || []
};
}
},
templateResult: function(r) {
if (!r.id) return r.text;
const img = r.photo ? '<img src="' + esc(r.photo) + '" onerror="this.outerHTML=\'<span class=&quot;ph&quot;></span>\'">' : '<span class="ph"></span>';
return $('<div class="mx-opt">' + img + '<div><b>' + esc(r.text) + '</b>' + (r.has_gm ? '' : ' <span class="tb tb-warn">senza g/m</span>') + '</div></div>');
}
}).on('change', function() {
state.m = parseInt($(this).val(), 10) || null;
load();
});
fetch('?action=customers').then(function(r) {
return r.json();
}).then(function(d) {
const $s = $('#pvCustomer');
$s.append(new Option('', '', false, false));
(d.customers || []).forEach(function(c) {
$s.append(new Option(c.name + ' (' + c.code + ')', c.code, false, c.code === state.c));
});
$s.select2({
theme: 'bootstrap-5',
placeholder: 'Nessun cliente: prezzo di mercato',
allowClear: true,
width: '100%'
})
.on('change', function() {
const v = $(this).val() || '';
if (v === (state.c || '')) return;
state.c = v;
load();
});
});
$('#pvUom').on('click', 'button', function() {
state.u = $(this).attr('data-u');
load();
});
$('#pvMixBody').on('input', 'input', calc);
$('#pvScrap, #pvOther').on('input', calc);
$('#pvMarkup').on('input', function() {
markupTouched = true;
calc();
});
$('#pvOffered').on('input', function() {
offeredTouched = true;
calc();
});
$('#pvOfferedInfo').on('click', '#pvResetOffered', function(e) {
e.preventDefault();
offeredTouched = false;
calc();
});
$('#pvChips').on('click', '.chip-mk', function() {
markupTouched = true;
$('#pvMarkup').val($(this).attr('data-v'));
calc();
});
$('#pvSave').on('click', save);
$('#pvPrint').on('click', function() {
window.print();
});
$('#pvQuotes').on('click', '.pv-reopen', function() {
reopen(data.quotes[+$(this).attr('data-i')]);
}).on('click', '.pv-del', function() {
const id = $(this).attr('data-id');
Swal.fire({
title: 'Eliminare il preventivo?',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Sì, elimina',
cancelButtonText: 'Annulla',
confirmButtonColor: '#d33'
})
.then(function(res) {
if (!res.isConfirmed) return;
fetch('sales_preventivo.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'action=delete&id=' + encodeURIComponent(id)
})
.then(function(r) {
return r.json();
}).then(function() {
data.quotes = data.quotes.filter(function(x) {
return String(x.id) !== String(id);
});
renderQuotes();
});
});
});
// matrice arrivata dall'URL (es. dalla pagina Matrici): la mostro nella tendina
if (state.m) {
load();
} else load();
});
})();
</script>
<script src="sales_help.js?v=6"></script>
</body>
</html>