Warenwirtschaft: AP-09 bis AP-13 (Produktbestand, Set-Produkte, Ausschuss, Konzepte)

- AP-09 Produktbestand inkl. Bewegungshistorie (product_stock_movements, ProductStockService)
- AP-10 Rohstoffbestand-Ansicht je Lager (RawMaterialStockController)
- AP-11 Bestandsschwellen / Out-of-Stock-Handling fuer Produkte und Shop
- AP-12 Ausgang/Ausschuss (stock_disposals, StockDisposalController, InventoryService)
- Set-Produkte (product_set_items) inkl. Aufloesung
- Produktentwicklung & Hinweise-Verwaltung (Notices)
- AP-13 Entwicklungskonzept Shop-Bestandsabzug im Plan dokumentiert
- Feature-Tests fuer neue Module + aktualisierter Entwicklungsplan

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin Adametz 2026-06-03 11:04:22 +00:00
parent 78679e0c55
commit 3ee2d756e9
63 changed files with 5968 additions and 901 deletions

View file

@ -0,0 +1,71 @@
@php($isEdit = $isEdit ?? false)
<div class="form-row">
<div class="form-group col-12 col-md-6">
<label for="product_id">{{ __('Produkt') }}</label>
<select name="product_id" id="product_id" class="form-control @error('product_id') is-invalid @enderror" required>
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($products as $p)
<option value="{{ $p->id }}" @selected(old('product_id', $model?->product_id ?? ($defaultProductId ?? null)) == $p->id)>{{ $p->name }}</option>
@endforeach
</select>
@error('product_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group col-12 col-md-6">
<label for="location_id">{{ __('Lagerort / Produktionsstandort') }}</label>
<select name="location_id" id="location_id" class="form-control @error('location_id') is-invalid @enderror" required>
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($locations as $loc)
<option value="{{ $loc->id }}" @selected(old('location_id', $model?->location_id ?? $defaultLocationId) == $loc->id)>{{ $loc->name }}</option>
@endforeach
</select>
@error('location_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
<div class="form-row">
<div class="form-group col-12 col-sm-6">
<label for="produced_at">{{ __('Produktionsdatum') }}</label>
<input type="text" name="produced_at" id="produced_at" autocomplete="off"
class="form-control datepicker-base @error('produced_at') is-invalid @enderror"
value="{{ old('produced_at', $isEdit ? $model?->produced_at?->format('d.m.Y') : now()->format('d.m.Y')) }}" required>
@error('produced_at')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group col-12 col-sm-6">
<label for="quantity">{{ __('Produzierte Stückzahl') }}</label>
<input type="number" name="quantity" id="quantity" min="1" step="1" class="form-control @error('quantity') is-invalid @enderror"
value="{{ old('quantity', $model?->quantity ?? 1) }}" required>
@error('quantity')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
<div id="recipe-warning" class="alert alert-warning" style="display:none;"></div>
<div id="recipe-area" class="mb-3" style="display:none;">
<hr>
<h6>{{ __('Chargen zuordnen') }}</h6>
<p class="text-muted small" id="recipe-hint"></p>
<div id="recipe-ingredient-lines"></div>
@error('ingredient_lines')
<div class="text-danger small">{{ $message }}</div>
@enderror
<h6 class="mt-3">{{ __('Verpackung (Vorschau)') }}</h6>
<div class="table-responsive border rounded">
<table class="table table-sm mb-0">
<thead><tr><th>{{ __('Artikel') }}</th><th>{{ __('Stück gesamt') }}</th></tr></thead>
<tbody id="recipe-packaging-preview"></tbody>
</table>
</div>
</div>
<div class="form-group">
<label for="notes">{{ __('Notizen') }}</label>
<textarea name="notes" id="notes" class="form-control" rows="2">{{ old('notes', $model?->notes) }}</textarea>
</div>

View file

@ -0,0 +1,213 @@
<script>
(function ($) {
var recipeBase = @json(url('/admin/inventory/api/products'));
var existingLines = @json($existingLines ?? (object) []);
var excludeProductionId = @json($excludeProductionId ?? null);
var lineIndex = 0;
var recipeIngredients = [];
var loadedKey = null;
function currentKey() {
return ($('#product_id').val() || '') + '|' + ($('#location_id').val() || '');
}
function recipeUrl() {
var pid = $('#product_id').val();
var lid = $('#location_id').val();
var qty = $('#quantity').val() || 1;
if (!pid || !lid) {
return null;
}
var url = recipeBase + '/' + pid + '/recipe?location_id=' + encodeURIComponent(lid) + '&quantity=' + encodeURIComponent(qty);
if (excludeProductionId) {
url += '&exclude_production=' + encodeURIComponent(excludeProductionId);
}
return url;
}
function fmt(value) {
return Number(value).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
function buildStockSelect(idx, stockEntries, preselectedEntryId) {
var select = $('<select class="form-control form-control-sm" name="ingredient_lines[' + idx + '][stock_entry_id]" required></select>');
select.append('<option value="">{{ __('Charge wählen') }}</option>');
(stockEntries || []).forEach(function (se) {
var label = se.label;
if (se.remaining != null) {
label += ' — {{ __('Rest') }} ' + fmt(se.remaining) + ' g';
}
var opt = $('<option></option>').attr('value', se.id).text(label);
if (preselectedEntryId && String(se.id) === String(preselectedEntryId)) {
opt.prop('selected', true);
}
select.append(opt);
});
return select;
}
function addIngredientRow($wrap, ing, preselectedEntryId, preselectedQty) {
var idx = lineIndex++;
var $row = $('<div class="form-row align-items-center mb-1 recipe-row"></div>');
var $colSelect = $('<div class="col-12 col-sm-7 mb-1"></div>');
$colSelect.append($('<input type="hidden" name="ingredient_lines[' + idx + '][ingredient_id]">').val(ing.id));
$colSelect.append(buildStockSelect(idx, ing.stock_entries, preselectedEntryId));
$row.append($colSelect);
var $colQty = $('<div class="col-12 col-sm-5 mb-1"></div>');
var $group = $('<div class="input-group input-group-sm"></div>');
$group.append($('<input type="text" class="form-control" required placeholder="0" name="ingredient_lines[' + idx + '][quantity_used]">').val(preselectedQty || ''));
$group.append('<div class="input-group-append"><span class="input-group-text">g</span></div>');
$colQty.append($group);
$row.append($colQty);
$wrap.find('.recipe-rows').first().append($row);
}
function recomputeSoll() {
var qty = parseInt($('#quantity').val(), 10) || 1;
$('#recipe-ingredient-lines [data-ingredient-id]').each(function () {
var $wrap = $(this);
var ing = $wrap.data('recipe-ing');
if (!ing || ing.gram == null) {
return;
}
var soll = ing.gram * (ing.factor || 1) * qty;
$wrap.find('.recipe-soll').text(fmt(soll));
});
}
function renderRecipe(data) {
recipeIngredients = data.ingredients || [];
$('#recipe-ingredient-lines').empty();
lineIndex = 0;
if (data.recipe_required === false) {
$('#recipe-warning').hide();
$('#btn-submit-production').prop('disabled', false);
$('#recipe-area').show();
$('#recipe-hint')
.text('{{ __('Dieses Produkt benötigt keine Rezeptur. Es wird ohne Rohstoffverbrauch produziert.') }}')
.removeClass('text-danger').addClass('text-muted');
var $pkOnly = $('#recipe-packaging-preview').empty();
(data.packagings || []).forEach(function (pk) {
$pkOnly.append($('<tr></tr>')
.append($('<td></td>').text(pk.name))
.append($('<td></td>').text(pk.total_pieces)));
});
return;
}
if (data.has_recipe === false) {
$('#recipe-area').hide();
$('#recipe-warning')
.text('{{ __('Für dieses Produkt ist keine Hersteller-Rezeptur gepflegt. Eine Produktion ist erst möglich, wenn eine Hersteller-Rezeptur hinterlegt wurde.') }}')
.show();
$('#btn-submit-production').prop('disabled', true);
return;
}
$('#recipe-warning').hide();
$('#btn-submit-production').prop('disabled', false);
$('#recipe-area').show();
var qty = parseInt($('#quantity').val(), 10) || 1;
var hintParts = [];
var hasMissingGram = false;
recipeIngredients.forEach(function (ing) {
if (ing.gram == null) {
hasMissingGram = true;
hintParts.push(ing.name + ': {{ __('Anteil in der Hersteller-Rezeptur fehlt') }}');
return;
}
var soll = ing.gram * (ing.factor || 1) * qty;
var $wrap = $('<div class="border rounded p-2 mb-2" data-ingredient-id="' + ing.id + '"></div>');
$wrap.data('recipe-ing', ing);
var $head = $('<div class="d-flex justify-content-between flex-wrap"></div>');
$head.append($('<strong></strong>').text(ing.name));
$head.append($('<span class="text-muted small"></span>').html('{{ __('Soll') }}: <span class="recipe-soll">' + fmt(soll) + '</span> g'));
$wrap.append($head);
$wrap.append('<div class="recipe-rows mt-1"></div>');
$wrap.append('<button type="button" class="btn btn-sm btn-outline-secondary btn-add-split mt-1">{{ __('Weitere Charge') }}</button>');
$('#recipe-ingredient-lines').append($wrap);
var existing = existingLines[String(ing.id)] || [];
if (existing.length > 0) {
existing.forEach(function (ex) {
addIngredientRow($wrap, ing, ex.stock_entry_id, ex.quantity_used);
});
} else {
addIngredientRow($wrap, ing);
}
});
if (hasMissingGram) {
$('#recipe-hint').text(hintParts.join(' · ')).removeClass('text-muted').addClass('text-danger');
} else {
$('#recipe-hint')
.text('{{ __('Pro Charge die entnommene Menge in Gramm eintragen. Summe je Inhaltsstoff muss dem Soll entsprechen.') }}')
.removeClass('text-danger').addClass('text-muted');
}
var $pk = $('#recipe-packaging-preview').empty();
(data.packagings || []).forEach(function (pk) {
$pk.append($('<tr></tr>')
.append($('<td></td>').text(pk.name))
.append($('<td></td>').text(pk.total_pieces)));
});
}
$(document).on('click', '.btn-add-split', function (e) {
e.preventDefault();
var $wrap = $(this).closest('[data-ingredient-id]');
var ing = $wrap.data('recipe-ing');
if (!ing) {
return;
}
addIngredientRow($wrap, ing);
});
function loadRecipe() {
var url = recipeUrl();
if (!url) {
$('#recipe-area').hide();
$('#recipe-warning').hide();
return;
}
$('#recipe-warning').hide();
$('#recipe-area').show();
$('#recipe-hint').text('{{ __('Lade ') }}');
$.getJSON(url)
.done(function (data) {
loadedKey = currentKey();
renderRecipe(data);
})
.fail(function () {
$('#recipe-area').show();
$('#recipe-hint').text('{{ __('Rezept konnte nicht geladen werden.') }}').addClass('text-danger');
});
}
$('#product_id, #location_id').on('change', function () {
existingLines = {};
loadRecipe();
});
$('#quantity').on('change input', function () {
if (loadedKey && loadedKey === currentKey() && recipeIngredients.length > 0) {
recomputeSoll();
} else {
loadRecipe();
}
});
$(document).ready(function () {
if ($('#product_id').val() && $('#location_id').val()) {
loadRecipe();
}
});
})(jQuery);
</script>

View file

@ -7,73 +7,7 @@
<div class="card-body">
<form method="post" action="{{ route('admin.inventory.productions.store') }}" id="form-production">
@csrf
<div class="form-row">
<div class="form-group col-md-6">
<label for="product_id">{{ __('Produkt') }}</label>
<select name="product_id" id="product_id" class="form-control @error('product_id') is-invalid @enderror" required>
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($products as $p)
<option value="{{ $p->id }}" @selected(old('product_id', $model?->product_id) == $p->id)>{{ $p->name }}</option>
@endforeach
</select>
@error('product_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group col-md-6">
<label for="location_id">{{ __('Lagerort / Produktionsstandort') }}</label>
<select name="location_id" id="location_id" class="form-control @error('location_id') is-invalid @enderror" required>
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($locations as $loc)
<option value="{{ $loc->id }}" @selected(old('location_id', $model?->location_id ?? $defaultLocationId) == $loc->id)>{{ $loc->name }}</option>
@endforeach
</select>
@error('location_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
<div class="form-row">
<div class="form-group col-md-4">
<label for="produced_at">{{ __('Produktionsdatum') }}</label>
<input type="date" name="produced_at" id="produced_at" class="form-control @error('produced_at') is-invalid @enderror"
value="{{ old('produced_at', now()->toDateString()) }}" required>
@error('produced_at')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group col-md-4">
<label for="quantity">{{ __('Produzierte Stückzahl') }}</label>
<input type="number" name="quantity" id="quantity" min="1" step="1" class="form-control @error('quantity') is-invalid @enderror"
value="{{ old('quantity', $model?->quantity ?? 1) }}" required>
@error('quantity')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
<div id="recipe-area" class="mb-3" style="display:none;">
<hr>
<h6>{{ __('Chargen zuordnen') }}</h6>
<p class="text-muted small" id="recipe-hint"></p>
<div id="recipe-ingredient-lines"></div>
@error('ingredient_lines')
<div class="text-danger small">{{ $message }}</div>
@enderror
<h6 class="mt-3">{{ __('Verpackung (Vorschau)') }}</h6>
<div class="table-responsive border rounded">
<table class="table table-sm mb-0">
<thead><tr><th>{{ __('Artikel') }}</th><th>{{ __('Stück gesamt') }}</th></tr></thead>
<tbody id="recipe-packaging-preview"></tbody>
</table>
</div>
</div>
<div class="form-group">
<label for="notes">{{ __('Notizen') }}</label>
<textarea name="notes" id="notes" class="form-control" rows="2">{{ old('notes') }}</textarea>
</div>
@include('admin.inventory.productions._form_fields', ['isEdit' => false])
<button type="submit" class="btn btn-primary" id="btn-submit-production">{{ __('Produktion speichern') }}</button>
<a href="{{ route('admin.inventory.productions.index') }}" class="btn btn-outline-secondary">{{ __('Abbrechen') }}</a>
@ -83,109 +17,12 @@
@endsection
@section('scripts')
<script>
(function ($) {
var recipeBase = @json(url('/admin/inventory/api/products'));
var lineIndex = 0;
function recipeUrl() {
var pid = $('#product_id').val();
var lid = $('#location_id').val();
var qty = $('#quantity').val() || 1;
if (!pid || !lid) return null;
return recipeBase + '/' + pid + '/recipe?location_id=' + encodeURIComponent(lid) + '&quantity=' + encodeURIComponent(qty);
}
function addIngredientRow($tbody, ing, stockEntries) {
var idx = lineIndex;
lineIndex++;
var tr = $('<tr></tr>');
var select = $('<select class="form-control form-control-sm" name="ingredient_lines[' + idx + '][stock_entry_id]" required></select>');
select.append('<option value="">{{ __('Charge wählen') }}</option>');
(stockEntries || []).forEach(function (se) {
var label = '#' + se.id;
if (se.batch_number) label += ' — ' + se.batch_number;
if (se.best_before) label += ' (MHD ' + se.best_before + ')';
select.append($('<option></option>').attr('value', se.id).text(label));
});
var td1 = $('<td></td>');
td1.append($('<input type="hidden" name="ingredient_lines[' + idx + '][ingredient_id]" value="' + ing.id + '">'));
td1.append(select);
tr.append(td1);
tr.append($('<td></td>').append(
$('<input type="text" class="form-control form-control-sm" required name="ingredient_lines[' + idx + '][quantity_used]" placeholder="0">')
));
$tbody.append(tr);
}
function renderRecipe(data) {
$('#recipe-ingredient-lines').empty();
lineIndex = 0;
var hintParts = [];
var hasMissingGram = false;
(data.ingredients || []).forEach(function (ing) {
if (ing.required_grams_total === null) {
hasMissingGram = true;
hintParts.push(ing.name + ': {{ __('Gramm in der Rezeptur fehlt') }}');
return;
}
var wrap = $('<div class="border rounded p-2 mb-2" data-ingredient-id="' + ing.id + '"></div>');
wrap.data('recipe-ing', ing);
var soll = '<strong>' + $('<div/>').text(ing.name).html() + '</strong> — {{ __('Soll') }}: ' +
ing.required_grams_total.toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' g';
wrap.append(soll);
var tbody = $('<tbody></tbody>');
var tbl = $('<table class="table table-sm table-bordered mb-1"><thead><tr><th>{{ __('Charge') }}</th><th>{{ __('Menge (g)') }}</th></tr></thead></table>');
tbl.append(tbody);
wrap.append(tbl);
wrap.append('<button type="button" class="btn btn-sm btn-outline-secondary btn-add-split">{{ __('Weitere Charge') }}</button>');
$('#recipe-ingredient-lines').append(wrap);
addIngredientRow(tbody, ing, ing.stock_entries || []);
});
if (hasMissingGram) {
$('#recipe-hint').text(hintParts.join(' · ')).removeClass('text-muted').addClass('text-danger');
} else {
$('#recipe-hint').text('{{ __('Pro Charge die entnommene Menge in Gramm eintragen. Summe je Inhaltsstoff muss dem Soll entsprechen.') }}').removeClass('text-danger').addClass('text-muted');
}
var $pk = $('#recipe-packaging-preview').empty();
(data.packagings || []).forEach(function (pk) {
$pk.append('<tr><td>' + $('<div/>').text(pk.name).html() + '</td><td>' + pk.total_pieces + '</td></tr>');
});
}
$(document).on('click', '.btn-add-split', function () {
var $wrap = $(this).closest('[data-ingredient-id]');
var ing = $wrap.data('recipe-ing');
if (!ing) return;
var $tbody = $wrap.find('tbody').first();
addIngredientRow($tbody, ing, ing.stock_entries || []);
});
function loadRecipe() {
var url = recipeUrl();
if (!url) {
$('#recipe-area').hide();
return;
}
$('#recipe-hint').text('{{ __('Lade ') }}');
$.getJSON(url)
.done(function (data) {
$('#recipe-area').show();
renderRecipe(data);
})
.fail(function () {
$('#recipe-area').show();
$('#recipe-hint').text('{{ __('Rezept konnte nicht geladen werden.') }}').addClass('text-danger');
});
}
$('#product_id, #location_id, #quantity').on('change', loadRecipe);
$(document).ready(function () {
if ($('#product_id').val() && $('#location_id').val()) {
loadRecipe();
}
});
})(jQuery);
</script>
@php
$existingLines = $model
? $model->productionIngredients->groupBy('ingredient_id')->map(function ($lines) {
return $lines->map(fn ($l) => ['stock_entry_id' => $l->stock_entry_id, 'quantity_used' => (float) $l->quantity_used])->values();
})
: [];
@endphp
@include('admin.inventory.productions._scripts', ['existingLines' => $existingLines, 'excludeProductionId' => null])
@endsection

View file

@ -8,73 +8,7 @@
<form method="post" action="{{ route('admin.inventory.productions.update', $model) }}" id="form-production">
@csrf
@method('PUT')
<div class="form-row">
<div class="form-group col-md-6">
<label for="product_id">{{ __('Produkt') }}</label>
<select name="product_id" id="product_id" class="form-control @error('product_id') is-invalid @enderror" required>
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($products as $p)
<option value="{{ $p->id }}" @selected(old('product_id', $model->product_id) == $p->id)>{{ $p->name }}</option>
@endforeach
</select>
@error('product_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group col-md-6">
<label for="location_id">{{ __('Lagerort / Produktionsstandort') }}</label>
<select name="location_id" id="location_id" class="form-control @error('location_id') is-invalid @enderror" required>
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($locations as $loc)
<option value="{{ $loc->id }}" @selected(old('location_id', $model->location_id) == $loc->id)>{{ $loc->name }}</option>
@endforeach
</select>
@error('location_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
<div class="form-row">
<div class="form-group col-md-4">
<label for="produced_at">{{ __('Produktionsdatum') }}</label>
<input type="date" name="produced_at" id="produced_at" class="form-control @error('produced_at') is-invalid @enderror"
value="{{ old('produced_at', $model->produced_at?->toDateString()) }}" required>
@error('produced_at')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group col-md-4">
<label for="quantity">{{ __('Produzierte Stückzahl') }}</label>
<input type="number" name="quantity" id="quantity" min="1" step="1" class="form-control @error('quantity') is-invalid @enderror"
value="{{ old('quantity', $model->quantity) }}" required>
@error('quantity')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
<div id="recipe-area" class="mb-3" style="display:none;">
<hr>
<h6>{{ __('Chargen zuordnen') }}</h6>
<p class="text-muted small" id="recipe-hint"></p>
<div id="recipe-ingredient-lines"></div>
@error('ingredient_lines')
<div class="text-danger small">{{ $message }}</div>
@enderror
<h6 class="mt-3">{{ __('Verpackung (Vorschau)') }}</h6>
<div class="table-responsive border rounded">
<table class="table table-sm mb-0">
<thead><tr><th>{{ __('Artikel') }}</th><th>{{ __('Stück gesamt') }}</th></tr></thead>
<tbody id="recipe-packaging-preview"></tbody>
</table>
</div>
</div>
<div class="form-group">
<label for="notes">{{ __('Notizen') }}</label>
<textarea name="notes" id="notes" class="form-control" rows="2">{{ old('notes', $model->notes) }}</textarea>
</div>
@include('admin.inventory.productions._form_fields', ['isEdit' => true])
<button type="submit" class="btn btn-primary" id="btn-submit-production">{{ __('Produktion speichern') }}</button>
<a href="{{ route('admin.inventory.productions.show', $model) }}" class="btn btn-outline-secondary">{{ __('Abbrechen') }}</a>
@ -84,120 +18,10 @@
@endsection
@section('scripts')
<script>
(function ($) {
var recipeBase = @json(url('/admin/inventory/api/products'));
var lineIndex = 0;
function recipeUrl() {
var pid = $('#product_id').val();
var lid = $('#location_id').val();
var qty = $('#quantity').val() || 1;
if (!pid || !lid) return null;
return recipeBase + '/' + pid + '/recipe?location_id=' + encodeURIComponent(lid) + '&quantity=' + encodeURIComponent(qty);
}
function addIngredientRow($tbody, ing, stockEntries, preselectedEntryId, preselectedQty) {
var idx = lineIndex;
lineIndex++;
var tr = $('<tr></tr>');
var select = $('<select class="form-control form-control-sm" name="ingredient_lines[' + idx + '][stock_entry_id]" required></select>');
select.append('<option value="">{{ __('Charge wählen') }}</option>');
(stockEntries || []).forEach(function (se) {
var label = '#' + se.id;
if (se.batch_number) label += ' — ' + se.batch_number;
if (se.best_before) label += ' (MHD ' + se.best_before + ')';
var opt = $('<option></option>').attr('value', se.id).text(label);
if (preselectedEntryId && String(se.id) === String(preselectedEntryId)) opt.prop('selected', true);
select.append(opt);
});
var td1 = $('<td></td>');
td1.append($('<input type="hidden" name="ingredient_lines[' + idx + '][ingredient_id]" value="' + ing.id + '">'));
td1.append(select);
tr.append(td1);
tr.append($('<td></td>').append(
$('<input type="text" class="form-control form-control-sm" required name="ingredient_lines[' + idx + '][quantity_used]" placeholder="0">').val(preselectedQty || '')
));
$tbody.append(tr);
}
function renderRecipe(data) {
$('#recipe-ingredient-lines').empty();
lineIndex = 0;
var existingLines = @json($model->productionIngredients->groupBy('ingredient_id')->map(function($lines) {
return $lines->map(function($l) {
return ['stock_entry_id' => $l->stock_entry_id, 'quantity_used' => (float) $l->quantity_used];
})->values();
}));
var hintParts = [];
var hasMissingGram = false;
(data.ingredients || []).forEach(function (ing) {
if (ing.required_grams_total === null) {
hasMissingGram = true;
hintParts.push(ing.name + ': {{ __('Gramm in der Rezeptur fehlt') }}');
return;
}
var wrap = $('<div class="border rounded p-2 mb-2" data-ingredient-id="' + ing.id + '"></div>');
wrap.data('recipe-ing', ing);
var soll = '<strong>' + $('<div/>').text(ing.name).html() + '</strong> — {{ __('Soll') }}: ' +
ing.required_grams_total.toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' g';
wrap.append(soll);
var tbody = $('<tbody></tbody>');
var tbl = $('<table class="table table-sm table-bordered mb-1"><thead><tr><th>{{ __('Charge') }}</th><th>{{ __('Menge (g)') }}</th></tr></thead></table>');
tbl.append(tbody);
wrap.append(tbl);
wrap.append('<button type="button" class="btn btn-sm btn-outline-secondary btn-add-split">{{ __('Weitere Charge') }}</button>');
$('#recipe-ingredient-lines').append(wrap);
var existing = existingLines[String(ing.id)] || [];
if (existing.length > 0) {
existing.forEach(function (ex) {
addIngredientRow(tbody, ing, ing.stock_entries || [], ex.stock_entry_id, ex.quantity_used);
});
} else {
addIngredientRow(tbody, ing, ing.stock_entries || []);
}
});
if (hasMissingGram) {
$('#recipe-hint').text(hintParts.join(' · ')).removeClass('text-muted').addClass('text-danger');
} else {
$('#recipe-hint').text('{{ __('Pro Charge die entnommene Menge in Gramm eintragen.') }}').removeClass('text-danger').addClass('text-muted');
}
var $pk = $('#recipe-packaging-preview').empty();
(data.packagings || []).forEach(function (pk) {
$pk.append('<tr><td>' + $('<div/>').text(pk.name).html() + '</td><td>' + pk.total_pieces + '</td></tr>');
});
}
$(document).on('click', '.btn-add-split', function () {
var $wrap = $(this).closest('[data-ingredient-id]');
var ing = $wrap.data('recipe-ing');
if (!ing) return;
var $tbody = $wrap.find('tbody').first();
addIngredientRow($tbody, ing, ing.stock_entries || []);
});
function loadRecipe() {
var url = recipeUrl();
if (!url) { $('#recipe-area').hide(); return; }
$('#recipe-hint').text('{{ __('Lade ') }}');
$.getJSON(url)
.done(function (data) { $('#recipe-area').show(); renderRecipe(data); })
.fail(function () {
$('#recipe-area').show();
$('#recipe-hint').text('{{ __('Rezept konnte nicht geladen werden.') }}').addClass('text-danger');
});
}
$('#product_id, #location_id, #quantity').on('change', loadRecipe);
$(document).ready(function () {
if ($('#product_id').val() && $('#location_id').val()) {
loadRecipe();
}
});
})(jQuery);
</script>
@php
$existingLines = $model->productionIngredients->groupBy('ingredient_id')->map(function ($lines) {
return $lines->map(fn ($l) => ['stock_entry_id' => $l->stock_entry_id, 'quantity_used' => (float) $l->quantity_used])->values();
});
@endphp
@include('admin.inventory.productions._scripts', ['existingLines' => $existingLines, 'excludeProductionId' => $model->id])
@endsection