gruene-seele/resources/views/admin/product/edit.blade.php
Kevin Adametz e53201f229 Warenwirtschaft: Anforderungsrunde 12.06. — Plan V5.0 + AP-26/AP-25/AP-22
Neue Anforderungen (docs/) interpretiert und als Entwicklungsplan V5.0
(AP-20 bis AP-28) aufgenommen; erste drei Pakete umgesetzt:

AP-26 Ausschuss-Gründe konfigurierbar:
- Stammdaten-Tabelle disposal_reasons + CRUD unter Einstellungen → Allgemein
- StockDisposalController liest aktive DB-Gründe statt hartkodierter Liste
- Seeder übernimmt die bisherigen 6 Gründe idempotent

AP-25 Lieferbestand — Datum statt Tage:
- "Nicht vorrätig" wird über Datepicker "Wieder lieferbar ab" gepflegt;
  Resttage-Hinweis zählt täglich automatisch herunter
- Interne Bestellliste wieder kaufbar: Hinweis erscheint zusätzlich zu
  den Mengen-Buttons (VP entscheidet selbst)

AP-22 Produktbestand-Erweiterungen:
- Default-Sortierung nach Dringlichkeit, Status-Kopf toggelt
- Alle vier Status-Kacheln als Filter klickbar
- Neue Spalte "Verbrauch/Monat" (Ø Abgänge der letzten 6 Monate)
- Produkt-Flag "Im Produktbestand anzeigen" (products.show_in_product_stock)

Tests: 77 grün (DisposalReasonSettings 8, ProductOutOfStock 8,
ProductStock 13 + Regression). Hinweise-Doku + Plan-Protokoll fortgeschrieben;
nächster Schritt laut Plan: AP-21 (INCI-Erweiterungen).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 16:28:45 +00:00

582 lines
25 KiB
PHP
Executable file

@extends('layouts.layout-2')
@section('content')
@if ($errors->any())
<div class="row">
<div class="col-sm-12">
<div class="alert alert-danger">
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
</div>
</div>
@endif
<h4 class="font-weight-bold py-2 mb-2">
{{ __('Create/Edit Produkt') }}
</h4>
{!! Form::open(['action' => route('admin_product_store'), 'class' => 'form-horizontal', 'id'=>'']) !!}
<input type="hidden" name="id" id="id" value="@if($product->id>0){{$product->id}}@else new @endif">
<div class="text-left mt-0 mb-2">
<button type="submit" class="btn btn-submit">{{ __('save') }}</button>&nbsp;
<a href="{{ route('admin_product_show') }}" class="btn btn-default">{{ __('back') }}</a>
</div>
@include('admin.product.form')
<div class="text-left mt-0 mb-2">
<button type="submit" class="btn btn-submit">{{ __('save') }}</button>&nbsp;
<a href="{{ route('admin_product_show') }}" class="btn btn-default">{{ __('back') }}</a>
</div>
{!! Form::close() !!}
@include('admin.product.images')
@include('admin.product.upload_whitelabel')
@endsection
@section('scripts')
@php
$ingredient_catalog_for_js = $ingredient_catalog->keyBy('id')->map(function ($item) {
return [
'id' => $item->id,
'name' => $item->name,
'inci' => $item->inci,
'effect' => $item->effect,
'default_factor' => $item->default_factor,
'quality_name' => $item->materialQuality?->name ?? '',
];
});
$packaging_catalog_for_js = $packaging_catalog->keyBy('id')->map(function ($item) {
return [
'name' => $item->name,
'weight_grams' => $item->weight_grams,
'material_name' => $item->packagingMaterial?->name ?? '',
];
});
$set_product_catalog_for_js = $set_product_catalog->keyBy('id')->map(function ($item) {
return [
'name' => $item->name,
'number' => $item->number,
'weight' => $item->weight ? $item->weight . ' g' : '—',
'price' => $item->getFormattedPrice() !== '' ? $item->getFormattedPrice() . ' €' : '—',
];
});
@endphp
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
<script>
(function ($) {
var catalog = @json($ingredient_catalog_for_js);
var packagingCatalog = @json($packaging_catalog_for_js);
var setCatalog = @json($set_product_catalog_for_js);
function parseDeNumber(val) {
if (val === undefined || val === null || val === '') return NaN;
var s = String(val).replace(/\s/g, '').replace(',', '.');
var n = parseFloat(s);
return isNaN(n) ? NaN : n;
}
function updateEffective($row) {
var g = parseDeNumber($row.find('.pi-gram').val());
var f = parseDeNumber($row.find('.pi-factor').val());
var $out = $row.find('.pi-effective');
if (!isNaN(g) && !isNaN(f)) {
var eff = g * f;
$out.text(eff.toLocaleString('de-DE', { minimumFractionDigits: 6, maximumFractionDigits: 6 }));
} else {
$out.text('—');
}
}
function updateRecipeTotal() {
var total = 0;
var hasValue = false;
$('#ingredient-sortable-rows tr').each(function () {
var v = parseDeNumber($(this).find('.pi-gram').val());
if (!isNaN(v)) { total += v; hasValue = true; }
});
var $cell = $('#recipe-total-percent');
if (!hasValue) {
$cell.text('—').removeClass('text-danger text-success');
return;
}
var formatted = total.toLocaleString('de-DE', { minimumFractionDigits: 6, maximumFractionDigits: 6 }) + ' %';
$cell.text(formatted);
var diff = Math.abs(total - 100);
if (diff < 0.000001) {
$cell.removeClass('text-danger').addClass('text-success');
$cell.attr('title', '');
} else {
$cell.removeClass('text-success').addClass('text-danger');
$cell.attr('title', 'Die Gesamtrezeptur ergibt nicht 100 %!');
}
}
function refreshAllEffectives() {
$('#ingredient-sortable-rows tr').each(function () { updateEffective($(this)); });
updateRecipeTotal();
}
$(document).ready(function () {
var $tbody = document.getElementById('ingredient-sortable-rows');
if ($tbody && typeof Sortable !== 'undefined') {
new Sortable($tbody, {
handle: '.ingredient-drag-handle',
animation: 150,
});
}
function appendIngredientRow(id) {
id = String(id);
var ing = catalog[id];
if (!ing) return;
var factor = ing.default_factor != null ? String(ing.default_factor).replace('.', ',') : '1,10';
var row = $('<tr data-ingredient-id="' + id + '">' +
'<td class="text-muted align-middle ingredient-drag-handle" style="cursor:grab">&#9776;</td>' +
'<td class="align-middle"></td><td class="align-middle small text-muted"></td><td class="align-middle"></td>' +
'<td><input type="hidden" name="pi_ingredient_id[]" value="' + id + '">' +
'<input type="text" name="pi_gram[]" class="form-control form-control-sm pi-gram" value="" autocomplete="off" step="0.000001"></td>' +
'<td><input type="text" name="pi_factor[]" class="form-control form-control-sm pi-factor" value="' + factor + '" autocomplete="off"></td>' +
'<td class="align-middle pi-effective text-right small text-muted">—</td>' +
'<td class="align-middle"><a class="text-danger ingredient-row-remove" href="#" title="Entfernen"><i class="far fa-trash-alt"></i></a></td></tr>');
row.find('td').eq(1).text(ing.name || '');
row.find('td').eq(2).text(ing.quality_name || '—');
row.find('td').eq(3).text(ing.inci || '');
$('#ingredient-sortable-rows').append(row);
updateEffective(row);
updateRecipeTotal();
}
function syncIngredientModalRows() {
var usedIds = {};
$('#ingredient-sortable-rows tr').each(function () {
var id = $(this).data('ingredient-id');
if (id) usedIds[String(id)] = true;
});
$('#modal-ingredients-pick .ingredient-modal-row').each(function () {
var id = String($(this).data('ingredient-id'));
var inUse = !!usedIds[id];
var $cb = $(this).find('.js-ingredient-modal-cb');
$cb.prop('disabled', inUse);
$cb.attr('title', inUse ? '{{ __('Bereits im Produkt') }}' : '');
if (inUse) {
$cb.prop('checked', false);
}
$(this).toggleClass('text-muted', inUse);
$(this).css('opacity', inUse ? 0.65 : 1);
$(this).find('.js-ing-modal-name').toggleClass('text-muted', inUse);
$(this).find('.js-ing-modal-inci').toggleClass('text-muted', inUse);
});
}
function filterIngredientModalRows() {
var q = $('#ingredient-modal-search').val().trim().toLowerCase();
var visible = 0;
$('#ingredient-modal-tbody .ingredient-modal-row').each(function () {
var hay = $(this).attr('data-ingredient-search') || '';
var match = !q || hay.indexOf(q) !== -1;
$(this).toggle(match);
if (match) {
visible++;
}
});
$('#ingredient-modal-no-results').toggleClass('d-none', !(q.length > 0 && visible === 0));
}
$('#ingredient-modal-search').on('input', filterIngredientModalRows);
$('#modal-ingredients-pick').on('show.bs.modal', function () {
$('#ingredient-modal-search').val('');
$('#ingredient-modal-tbody .ingredient-modal-row').show();
$('#ingredient-modal-no-results').addClass('d-none');
syncIngredientModalRows();
});
$('#btn-ingredients-modal-add').on('click', function () {
var added = 0;
$('#modal-ingredients-pick .js-ingredient-modal-cb:checked:not(:disabled)').each(function () {
var id = String($(this).val());
appendIngredientRow(id);
$(this).prop('checked', false);
added++;
});
if (added > 0) {
syncIngredientModalRows();
$('#modal-ingredients-pick').modal('hide');
}
});
$(document).on('click', '.ingredient-row-remove', function (ev) {
ev.preventDefault();
var $tr = $(this).closest('tr');
$tr.remove();
syncIngredientModalRows();
updateRecipeTotal();
});
$(document).on('input change', '.pi-gram, .pi-factor', function () {
updateEffective($(this).closest('tr'));
updateRecipeTotal();
});
refreshAllEffectives();
var $packTbody = document.getElementById('packaging-sortable-rows');
if ($packTbody && typeof Sortable !== 'undefined') {
new Sortable($packTbody, {
handle: '.packaging-drag-handle',
animation: 150,
});
}
function appendPackagingRow(id) {
id = String(id);
var pk = packagingCatalog[id];
if (!pk) {
return;
}
var wCell = '—';
if (pk.weight_grams != null && pk.weight_grams !== '') {
wCell = String(pk.weight_grams).replace('.', ',');
}
var row = $('<tr data-packaging-item-id="' + id + '">' +
'<td class="text-muted align-middle packaging-drag-handle" style="cursor:grab">&#9776;</td>' +
'<td class="align-middle"></td><td class="align-middle"></td><td class="align-middle text-right"></td>' +
'<td><input type="hidden" name="pp_packaging_item_id[]" value="' + id + '">' +
'<input type="text" name="pp_quantity[]" class="form-control form-control-sm pp-quantity" value="1" autocomplete="off"></td>' +
'<td class="align-middle"><a class="text-danger packaging-row-remove" href="#" title="{{ __('Entfernen') }}"><i class="far fa-trash-alt"></i></a></td></tr>');
row.find('td').eq(1).text(pk.name || '');
row.find('td').eq(2).text(pk.material_name || '—');
row.find('td').eq(3).text(wCell);
$('#packaging-sortable-rows').append(row);
}
function syncPackagingModalRows() {
var usedIds = {};
$('#packaging-sortable-rows tr').each(function () {
var pid = $(this).data('packaging-item-id');
if (pid) {
usedIds[String(pid)] = true;
}
});
$('#modal-packaging-pick .packaging-modal-row').each(function () {
var pid = String($(this).data('packaging-item-id'));
var inUse = !!usedIds[pid];
var $cb = $(this).find('.js-packaging-modal-cb');
$cb.prop('disabled', inUse);
$cb.attr('title', inUse ? '{{ __('Bereits im Produkt') }}' : '');
if (inUse) {
$cb.prop('checked', false);
}
$(this).toggleClass('text-muted', inUse);
$(this).css('opacity', inUse ? 0.65 : 1);
$(this).find('.js-pk-modal-name').toggleClass('text-muted', inUse);
$(this).find('.js-pk-modal-mat').toggleClass('text-muted', inUse);
});
}
function filterPackagingModalRows() {
var q = $('#packaging-modal-search').val().trim().toLowerCase();
var visible = 0;
$('#packaging-modal-tbody .packaging-modal-row').each(function () {
var hay = $(this).attr('data-packaging-search') || '';
var match = !q || hay.indexOf(q) !== -1;
$(this).toggle(match);
if (match) {
visible++;
}
});
$('#packaging-modal-no-results').toggleClass('d-none', !(q.length > 0 && visible === 0));
}
$('#packaging-modal-search').on('input', filterPackagingModalRows);
$('#modal-packaging-pick').on('show.bs.modal', function () {
$('#packaging-modal-search').val('');
$('#packaging-modal-tbody .packaging-modal-row').show();
$('#packaging-modal-no-results').addClass('d-none');
syncPackagingModalRows();
});
$('#btn-packaging-modal-add').on('click', function () {
var added = 0;
$('#modal-packaging-pick .js-packaging-modal-cb:checked:not(:disabled)').each(function () {
var id = String($(this).val());
appendPackagingRow(id);
$(this).prop('checked', false);
added++;
});
if (added > 0) {
syncPackagingModalRows();
$('#modal-packaging-pick').modal('hide');
}
});
$(document).on('click', '.packaging-row-remove', function (ev) {
ev.preventDefault();
$(this).closest('tr').remove();
syncPackagingModalRows();
});
// === Hersteller-Rezeptur (Manufacturer) ===
var $mfgTbody = document.getElementById('mfg-ingredient-sortable-rows');
if ($mfgTbody && typeof Sortable !== 'undefined') {
new Sortable($mfgTbody, {
handle: '.mfg-ingredient-drag-handle',
animation: 150,
});
}
function updateMfgEffective($row) {
var g = parseDeNumber($row.find('.mfg-gram').val());
var f = parseDeNumber($row.find('.mfg-factor').val());
var $out = $row.find('.mfg-effective');
if (!isNaN(g) && !isNaN(f)) {
$out.text((g * f).toLocaleString('de-DE', { minimumFractionDigits: 6, maximumFractionDigits: 6 }));
} else {
$out.text('—');
}
}
function updateMfgRecipeTotal() {
var total = 0, hasValue = false;
$('#mfg-ingredient-sortable-rows tr').each(function () {
var v = parseDeNumber($(this).find('.mfg-gram').val());
if (!isNaN(v)) { total += v; hasValue = true; }
});
var $cell = $('#mfg-recipe-total-percent');
if (!hasValue) { $cell.text('—').removeClass('text-danger text-success'); return; }
$cell.text(total.toLocaleString('de-DE', { minimumFractionDigits: 6, maximumFractionDigits: 6 }) + ' %');
if (Math.abs(total - 100) < 0.000001) {
$cell.removeClass('text-danger').addClass('text-success').attr('title', '');
} else {
$cell.removeClass('text-success').addClass('text-danger').attr('title', 'Die Gesamtrezeptur ergibt nicht 100 %!');
}
}
function refreshAllMfgEffectives() {
$('#mfg-ingredient-sortable-rows tr').each(function () { updateMfgEffective($(this)); });
updateMfgRecipeTotal();
}
function appendMfgIngredientRow(id) {
id = String(id);
var ing = catalog[id];
if (!ing) return;
var factor = ing.default_factor != null ? String(ing.default_factor).replace('.', ',') : '1,10';
var row = $('<tr data-ingredient-id="' + id + '">' +
'<td class="text-muted align-middle mfg-ingredient-drag-handle" style="cursor:grab">&#9776;</td>' +
'<td class="align-middle"></td><td class="align-middle small text-muted"></td><td class="align-middle"></td>' +
'<td><input type="hidden" name="mfg_ingredient_id[]" value="' + id + '">' +
'<input type="text" name="mfg_gram[]" class="form-control form-control-sm mfg-gram" value="" autocomplete="off" step="0.000001"></td>' +
'<td><input type="text" name="mfg_factor[]" class="form-control form-control-sm mfg-factor" value="' + factor + '" autocomplete="off"></td>' +
'<td class="align-middle mfg-effective text-right small text-muted">—</td>' +
'<td class="align-middle"><a class="text-danger mfg-ingredient-row-remove" href="#" title="Entfernen"><i class="far fa-trash-alt"></i></a></td></tr>');
row.find('td').eq(1).text(ing.name || '');
row.find('td').eq(2).text(ing.quality_name || '—');
row.find('td').eq(3).text(ing.inci || '');
$('#mfg-ingredient-sortable-rows').append(row);
updateMfgEffective(row);
updateMfgRecipeTotal();
}
function syncMfgIngredientModalRows() {
var usedIds = {};
$('#mfg-ingredient-sortable-rows tr').each(function () {
var id = $(this).data('ingredient-id');
if (id) usedIds[String(id)] = true;
});
$('#modal-mfg-ingredients-pick .mfg-ingredient-modal-row').each(function () {
var id = String($(this).data('ingredient-id'));
var inUse = !!usedIds[id];
var $cb = $(this).find('.js-mfg-ingredient-modal-cb');
$cb.prop('disabled', inUse);
if (inUse) $cb.prop('checked', false);
$(this).css('opacity', inUse ? 0.65 : 1);
});
}
$('#mfg-ingredient-modal-search').on('input', function () {
var q = $(this).val().trim().toLowerCase();
var visible = 0;
$('#mfg-ingredient-modal-tbody .mfg-ingredient-modal-row').each(function () {
var hay = $(this).attr('data-ingredient-search') || '';
var match = !q || hay.indexOf(q) !== -1;
$(this).toggle(match);
if (match) visible++;
});
$('#mfg-ingredient-modal-no-results').toggleClass('d-none', !(q.length > 0 && visible === 0));
});
$('#modal-mfg-ingredients-pick').on('show.bs.modal', function () {
$('#mfg-ingredient-modal-search').val('');
$('#mfg-ingredient-modal-tbody .mfg-ingredient-modal-row').show();
$('#mfg-ingredient-modal-no-results').addClass('d-none');
syncMfgIngredientModalRows();
});
$('#btn-mfg-ingredients-modal-add').on('click', function () {
var added = 0;
$('#modal-mfg-ingredients-pick .js-mfg-ingredient-modal-cb:checked:not(:disabled)').each(function () {
appendMfgIngredientRow(String($(this).val()));
$(this).prop('checked', false);
added++;
});
if (added > 0) { syncMfgIngredientModalRows(); $('#modal-mfg-ingredients-pick').modal('hide'); }
});
$(document).on('click', '.mfg-ingredient-row-remove', function (ev) {
ev.preventDefault();
$(this).closest('tr').remove();
syncMfgIngredientModalRows();
updateMfgRecipeTotal();
});
$(document).on('input change', '.mfg-gram, .mfg-factor', function () {
updateMfgEffective($(this).closest('tr'));
updateMfgRecipeTotal();
});
refreshAllMfgEffectives();
function toggleShelfMonths() {
var v = $('.js-shelf-life-type:checked').val();
if (v === 'fixed') {
$('#shelf-life-months-wrap').show();
} else {
$('#shelf-life-months-wrap').hide();
}
}
$(document).on('change', '.js-shelf-life-type', toggleShelfMonths);
toggleShelfMonths();
function toggleRecipeFields() {
var noRecipe = $('#no_recipe_required').is(':checked');
$('.js-recipe-fields').toggle(!noRecipe);
}
$(document).on('change', '#no_recipe_required', toggleRecipeFields);
toggleRecipeFields();
// === Set-Bestandteile ===
var $setTbody = document.getElementById('set-sortable-rows');
if ($setTbody && typeof Sortable !== 'undefined') {
new Sortable($setTbody, {
handle: '.set-drag-handle',
animation: 150,
});
}
function appendSetRow(id) {
id = String(id);
var sp = setCatalog[id];
if (!sp) {
return;
}
var row = $('<tr data-set-product-id="' + id + '">' +
'<td class="text-muted align-middle set-drag-handle" style="cursor:grab">&#9776;</td>' +
'<td class="align-middle"></td><td class="align-middle small text-muted"></td>' +
'<td class="align-middle text-right small text-muted"></td>' +
'<td class="align-middle text-right small text-muted"></td>' +
'<td><input type="hidden" name="set_component_id[]" value="' + id + '">' +
'<input type="number" min="1" step="1" name="set_quantity[]" class="form-control form-control-sm set-quantity" value="1" autocomplete="off"></td>' +
'<td class="align-middle"><a class="text-danger set-row-remove" href="#" title="{{ __('Entfernen') }}"><i class="far fa-trash-alt"></i></a></td></tr>');
row.find('td').eq(1).text(sp.name || '');
row.find('td').eq(2).text(sp.number || '—');
row.find('td').eq(3).text(sp.weight || '—');
row.find('td').eq(4).text(sp.price || '—');
$('#set-sortable-rows').append(row);
}
function syncSetModalRows() {
var usedIds = {};
$('#set-sortable-rows tr').each(function () {
var sid = $(this).data('set-product-id');
if (sid) {
usedIds[String(sid)] = true;
}
});
$('#modal-set-pick .set-modal-row').each(function () {
var sid = String($(this).data('set-product-id'));
var inUse = !!usedIds[sid];
var $cb = $(this).find('.js-set-modal-cb');
$cb.prop('disabled', inUse);
$cb.attr('title', inUse ? '{{ __('Bereits im Set') }}' : '');
if (inUse) {
$cb.prop('checked', false);
}
$(this).css('opacity', inUse ? 0.65 : 1);
$(this).find('.js-set-modal-name').toggleClass('text-muted', inUse);
$(this).find('.js-set-modal-number').toggleClass('text-muted', inUse);
});
}
$('#set-modal-search').on('input', function () {
var q = $(this).val().trim().toLowerCase();
var visible = 0;
$('#set-modal-tbody .set-modal-row').each(function () {
var hay = $(this).attr('data-set-search') || '';
var match = !q || hay.indexOf(q) !== -1;
$(this).toggle(match);
if (match) {
visible++;
}
});
$('#set-modal-no-results').toggleClass('d-none', !(q.length > 0 && visible === 0));
});
$('#modal-set-pick').on('show.bs.modal', function () {
$('#set-modal-search').val('');
$('#set-modal-tbody .set-modal-row').show();
$('#set-modal-no-results').addClass('d-none');
syncSetModalRows();
});
$('#btn-set-modal-add').on('click', function () {
var added = 0;
$('#modal-set-pick .js-set-modal-cb:checked:not(:disabled)').each(function () {
appendSetRow(String($(this).val()));
$(this).prop('checked', false);
added++;
});
if (added > 0) {
syncSetModalRows();
$('#modal-set-pick').modal('hide');
}
});
$(document).on('click', '.set-row-remove', function (ev) {
ev.preventDefault();
$(this).closest('tr').remove();
syncSetModalRows();
});
function toggleSetMode() {
var isSet = $('#is_set').is(':checked');
$('.js-set-fields').toggle(isSet);
$('#product-section-rezeptur, #product-section-herstellerrezeptur, #product-section-verpackung, #product-section-warenwirtschaft').toggle(!isSet);
$('.js-nav-recipe').toggleClass('d-none', isSet);
}
$(document).on('change', '#is_set', toggleSetMode);
toggleSetMode();
function toggleOutOfStock() {
var indefinite = $('#out_of_stock_indefinite').is(':checked');
var active = $('#out_of_stock_active').is(':checked');
$('#out_of_stock_active').prop('disabled', indefinite);
$('.js-out-of-stock-date').toggle(active && !indefinite);
}
$(document).on('change', '#out_of_stock_active, #out_of_stock_indefinite', toggleOutOfStock);
toggleOutOfStock();
});
})(jQuery);
</script>
@endsection