OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

calc.php

13 KB
<?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 &amp; 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 + '">&times;</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>