Quelltext des aktuellen Standes
<?php
$this->layout('layouts/main', [
'page_title' => $page_title,
'current_route' => $current_route,
'full_width' => true,
'extra_css' => ['/assets/css/catalog.css?v=' . filemtime(dirname(__DIR__, 2) . '/public/assets/css/catalog.css')],
]);
$disp = ($display ?? 'net') === 'gross' ? 'gross' : 'net';
?>
<div class="cat-wrap">
<div class="content-header">
<div>
<h1><?= $this->te('Kalkulation') ?></h1>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><?= $this->te('Home') ?></a></li>
<li class="breadcrumb-item"><a href="/catalog"><?= $this->te('Ausstattung & Preise') ?></a></li>
<li class="breadcrumb-item active"><?= $this->te('Kalkulation') ?></li>
</ol>
</div>
<div class="content-header-actions">
<a href="/catalog" class="btn btn-default"><i class="fas fa-arrow-left"></i> <?= $this->te('Zurück') ?></a>
</div>
</div>
<div class="page-body">
<div class="card card-outline card-primary">
<div class="filter-card-body cat-calc-add">
<div class="form-group cat-calc-item">
<label for="calc-item"><?= $this->te('Artikel hinzufügen') ?></label>
<select id="calc-item" class="form-control" data-searchable>
<option value=""><?= $this->te('– Artikel wählen –') ?></option>
<?php foreach (($items ?? []) as $i): ?>
<option value="<?= (int)$i['id'] ?>"><?= $this->e($i['name'] . ($i['category'] ? ' — ' . $i['category'] : '')) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group cat-calc-qty">
<label for="calc-qty"><?= $this->te('Menge') ?></label>
<input type="number" id="calc-qty" class="form-control" value="1" min="1">
</div>
<button type="button" class="btn btn-primary" id="calc-add"><i class="fas fa-plus"></i> <?= $this->te('Hinzufügen') ?></button>
<span class="cat-calc-sep"></span>
<div class="form-group cat-calc-profile">
<label for="calc-bundle"><?= $this->te('Profil laden') ?></label>
<select id="calc-bundle" class="form-control" data-searchable>
<option value=""><?= $this->te('– Profil wählen –') ?></option>
</select>
</div>
</div>
</div>
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-card-title"><?= $this->te('Zusammenstellung') ?></span>
<div>
<form id="calc-pdf-form" method="post" action="/catalog/calc/pdf" target="_blank" rel="noopener" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= $this->e($this->csrf()) ?>">
<span id="calc-pdf-fields"></span>
<button type="submit" class="btn btn-default btn-sm" id="calc-pdf"><i class="fas fa-file-pdf"></i> <?= $this->te('Als PDF') ?></button>
</form>
<button type="button" class="btn btn-default btn-sm" id="calc-copy"><i class="fas fa-copy"></i> <?= $this->te('Als Text kopieren') ?></button>
<button type="button" class="btn btn-default btn-sm" id="calc-clear"><?= $this->te('Leeren') ?></button>
</div>
</div>
<div class="detail-card-body">
<div class="table-wrapper">
<table class="data-table" id="calc-table">
<thead>
<tr><th scope="col"><?= $this->te('Artikel') ?></th><th scope="col"><?= $this->te('Abrechnung') ?></th><th scope="col" class="cat-col-num"><?= $this->te('Menge') ?></th><th scope="col" class="cat-col-price"><?= $this->te('Einzel (netto)') ?></th><th scope="col" class="cat-col-price"><?= $this->te('Summe (netto)') ?></th><th scope="col"></th></tr>
</thead>
<tbody id="calc-rows"></tbody>
</table>
<div class="empty-state" id="calc-empty"><p><?= $this->te('Noch nichts hinzugefügt. Wähle oben Artikel oder ein Profil.') ?></p></div>
</div>
<div class="cat-calc-totals" id="calc-totals"></div>
</div>
</div>
</div>
</div>
<script>
(function () {
var ITEMS = <?= $items_json ?? '[]' ?>;
var BUNDLES = <?= $bundles_json ?? '[]' ?>;
var REQS = <?= $requirements_json ?? '{}' ?>; // itemId -> [{id, qty}] Pflicht-Positionen
var byId = {};
ITEMS.forEach(function (i) { byId[i.id] = i; });
var cart = {}; // id -> qty
var requiredIds = {}; // id -> true, wenn (auch) als Pflicht-Position ergänzt
var cycleLabel = { once: 'einmalig', monthly: 'pro Monat', yearly: 'pro Jahr' };
// Das Artikel-Select wird serverseitig gefüllt und vom durchsuchbaren
// Dropdown (data-searchable) aufgewertet – hier nur die Profilliste ergänzen.
var bsel = document.getElementById('calc-bundle');
BUNDLES.forEach(function (b) {
var o = document.createElement('option'); o.value = b.id; o.textContent = b.name; bsel.appendChild(o);
});
// Beträge sehen aus wie die vom Server (zentraler Helfer aus app.js).
function fmt(n) { return window.OpenNIT.money.format(n); }
// Fügt einen Artikel hinzu und zieht seine Pflicht-Positionen automatisch
// nach (rekursiv, mit Zyklus-Schutz über das visited-Set).
function _add(id, qty, visited) {
id = parseInt(id, 10); qty = parseInt(qty, 10) || 1;
if (!byId[id] || qty <= 0) { return; }
cart[id] = (cart[id] || 0) + qty;
visited[id] = true;
(REQS[id] || []).forEach(function (r) {
if (!visited[r.id]) { requiredIds[r.id] = true; _add(r.id, r.qty * qty, visited); }
});
}
function addItem(id, qty) { _add(id, qty, {}); render(); }
function setQty(id, qty) { qty = parseInt(qty, 10); if (qty > 0) { cart[id] = qty; } else { delete cart[id]; } render(); }
function removeItem(id) { delete cart[id]; render(); }
function totalsByCycle() {
var t = { once: { net: 0, gross: 0 }, monthly: { net: 0, gross: 0 }, yearly: { net: 0, gross: 0 } };
Object.keys(cart).forEach(function (id) {
var it = byId[id]; if (!it) { return; }
var q = cart[id];
t[it.cycle].net += (it.net || 0) * q;
t[it.cycle].gross += (it.gross || 0) * q;
});
return t;
}
function render() {
var rows = document.getElementById('calc-rows');
var empty = document.getElementById('calc-empty');
rows.innerHTML = '';
var ids = Object.keys(cart);
empty.style.display = ids.length ? 'none' : '';
ids.forEach(function (id) {
var it = byId[id]; var q = cart[id];
var tr = document.createElement('tr');
var line = (it.net || 0) * q;
tr.innerHTML =
'<td>' + escapeHtml(it.name) +
(requiredIds[id] ? ' <span class="status-pill status-pill--info">Pflicht</span>' : '') +
(it.net === null ? ' <span class="status-pill status-pill--warning">kein Preis</span>' : '') + '</td>' +
'<td>' + (cycleLabel[it.cycle] || '') + '</td>' +
'<td class="cat-col-num"><input type="number" min="1" value="' + q + '" class="form-control form-control-sm cat-qty" data-id="' + id + '" style="width:5rem;"></td>' +
'<td class="cat-col-price">' + fmt(it.net || 0) + '</td>' +
'<td class="cat-col-price"><strong>' + fmt(line) + '</strong></td>' +
'<td><button type="button" class="btn btn-outline-danger btn-xs cat-rm" data-id="' + id + '">×</button></td>';
rows.appendChild(tr);
});
rows.querySelectorAll('.cat-qty').forEach(function (el) {
el.addEventListener('change', function () { setQty(this.dataset.id, this.value); });
});
rows.querySelectorAll('.cat-rm').forEach(function (el) {
el.addEventListener('click', function () { removeItem(this.dataset.id); });
});
renderTotals();
}
// Wiederkehrende Kosten zu einem Jahreswert verdichten (monatlich×12 +
// jährlich); es bleiben zwei Preise: einmalig und laufend.
function recurring(t) {
return { net: t.monthly.net * 12 + t.yearly.net, gross: t.monthly.gross * 12 + t.yearly.gross };
}
function renderTotals() {
var t = totalsByCycle();
var rec = recurring(t);
var box = document.getElementById('calc-totals');
var parts = [];
if (t.once.net !== 0 || t.once.gross !== 0) {
parts.push('<div class="cat-total"><span class="cat-total-label">Einmalig</span>' +
'<span class="cat-total-net">' + fmt(t.once.net) + ' netto</span>' +
'<span class="cat-total-gross">' + fmt(t.once.gross) + ' brutto</span></div>');
}
if (rec.net !== 0 || rec.gross !== 0) {
parts.push('<div class="cat-total cat-total--accent"><span class="cat-total-label">Laufend · pro Jahr</span>' +
'<span class="cat-total-net">' + fmt(rec.net) + ' netto</span>' +
'<span class="cat-total-gross">' + fmt(rec.gross) + ' brutto · ≈ ' + fmt(rec.net / 12) + '/Monat netto</span></div>');
}
box.innerHTML = parts.join('');
}
function asText() {
var lines = [];
Object.keys(cart).forEach(function (id) {
var it = byId[id]; var q = cart[id];
lines.push(q + '× ' + it.name + (requiredIds[id] ? ' [Pflicht]' : '') + ' — ' + fmt((it.net || 0) * q) + ' netto' + (it.cycle !== 'once' ? ' (' + cycleLabel[it.cycle] + ')' : ''));
});
var t = totalsByCycle();
var rec = recurring(t);
lines.push('');
if (t.once.net || t.once.gross) {
lines.push('Einmalig: ' + fmt(t.once.net) + ' netto / ' + fmt(t.once.gross) + ' brutto');
}
if (rec.net || rec.gross) {
lines.push('Laufend pro Jahr: ' + fmt(rec.net) + ' netto / ' + fmt(rec.gross) + ' brutto (≈ ' + fmt(rec.net / 12) + '/Monat netto)');
}
lines.push('Stand: ' + new Date().toLocaleDateString('de-DE'));
return lines.join('\n');
}
function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
document.getElementById('calc-add').addEventListener('click', function () {
var sel = document.getElementById('calc-item');
var qty = document.getElementById('calc-qty').value;
if (sel.value) {
addItem(sel.value, qty);
sel.value = '';
if (window.ssSync) { window.ssSync(sel); } // Trigger-Label zurücksetzen
}
});
document.getElementById('calc-clear').addEventListener('click', function () { cart = {}; requiredIds = {}; render(); });
bsel.addEventListener('change', function () {
var b = BUNDLES.find(function (x) { return String(x.id) === this.value; }.bind(this));
if (b) {
b.items.forEach(function (bi) { addItem(bi.id, bi.qty); });
this.value = '';
if (window.ssSync) { window.ssSync(this); }
}
});
document.getElementById('calc-copy').addEventListener('click', function () {
var text = asText();
var btn = this;
function done() { var o = btn.innerHTML; btn.innerHTML = '<i class="fas fa-check"></i> Kopiert'; setTimeout(function () { btn.innerHTML = o; }, 1500); }
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, function () { window.prompt('Kopieren mit Strg+C:', text); });
} else { window.prompt('Kopieren mit Strg+C:', text); }
});
// PDF-Druck: aktuellen Warenkorb zeilengetreu als verstecktes Feld posten.
// Der Server rechnet die Preise frisch und rendert das PDF (Contracts-Optik).
var pdfForm = document.getElementById('calc-pdf-form');
var pdfFields = document.getElementById('calc-pdf-fields');
pdfForm.addEventListener('submit', function (e) {
var ids = Object.keys(cart);
if (!ids.length) {
e.preventDefault();
if (window.OpenNIT && OpenNIT.notify) { OpenNIT.notify('Die Kalkulation ist leer.', 'warning'); }
else { window.alert('Die Kalkulation ist leer.'); }
return;
}
pdfFields.innerHTML = '';
ids.forEach(function (id) {
pdfFields.appendChild(hiddenInput('item_id[]', id));
pdfFields.appendChild(hiddenInput('qty[]', cart[id]));
pdfFields.appendChild(hiddenInput('required[]', requiredIds[id] ? '1' : '0'));
});
});
function hiddenInput(name, value) {
var i = document.createElement('input');
i.type = 'hidden'; i.name = name; i.value = value;
return i;
}
// Der zentrale Geld-Helfer steht erst nach den verzoegerten Skripten bereit.
document.addEventListener('DOMContentLoaded', render);
})();
</script>