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,44 @@
@extends('layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-2 mb-2">{{ __('Hinweise') }}</h4>
<div class="card">
<div class="card-body wawi-notices">
{!! $content !!}
</div>
</div>
@endsection
@section('styles')
<style>
.wawi-notices h1 {
font-size: 1.5rem;
}
.wawi-notices h2 {
font-size: 1.25rem;
margin-top: 1.5rem;
}
.wawi-notices h3 {
font-size: 1.05rem;
margin-top: 1rem;
}
.wawi-notices blockquote {
border-left: 3px solid #d4d8dd;
padding: 0.25rem 0 0.25rem 1rem;
color: #6c757d;
}
.wawi-notices hr {
margin: 1.5rem 0;
}
.wawi-notices ul {
padding-left: 1.25rem;
}
</style>
@endsection

View file

@ -0,0 +1,19 @@
@extends('layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-2 mb-2">{{ __('Produktentwicklung') }}</h4>
<div class="card">
<div class="card-body">
<div class="alert alert-info mb-0">
<h6 class="alert-heading font-weight-bold">{{ __('Briefing ausstehend') }}</h6>
<p class="mb-0">
{{ __('Dieser Bereich ist als Platzhalter angelegt. Zur konkreten Funktionsweise der Produktentwicklung steht noch ein genaues Briefing aus.') }}
</p>
<p class="mb-0 mt-2 text-muted small">
{{ __('Es findet hier aktuell keine Bestandsbuchung und keine Verarbeitungslogik statt.') }}
</p>
</div>
</div>
</div>
@endsection

View file

@ -0,0 +1,110 @@
@extends('layouts.layout-2')
@php
$months = [
1 => __('Januar'), 2 => __('Februar'), 3 => __('März'), 4 => __('April'),
5 => __('Mai'), 6 => __('Juni'), 7 => __('Juli'), 8 => __('August'),
9 => __('September'), 10 => __('Oktober'), 11 => __('November'), 12 => __('Dezember'),
];
@endphp
@section('content')
@include('admin.inventory.partials.table-actions-style')
<div class="card">
<h6 class="card-header">{{ __('Produktbestand Historie') }}</h6>
<div class="card-body pb-0">
<form method="get" action="{{ route('admin.inventory.product-stock.history') }}">
<div class="form-row">
<div class="form-group col-md-3">
<label class="small text-muted">{{ __('Produkt') }}</label>
<select name="product_id" class="form-control" onchange="this.form.submit()">
<option value="">{{ __('Filter aus') }}</option>
@foreach ($products as $p)
<option value="{{ $p->id }}" @selected($filters['product_id'] === $p->id)>{{ $p->name }}</option>
@endforeach
</select>
</div>
<div class="form-group col-md-2">
<label class="small text-muted">{{ __('Eingang / Ausgang') }}</label>
<select name="direction" class="form-control" onchange="this.form.submit()">
<option value="">{{ __('Filter aus') }}</option>
<option value="in" @selected($filters['direction'] === 'in')>{{ __('Eingang') }}</option>
<option value="out" @selected($filters['direction'] === 'out')>{{ __('Ausgang') }}</option>
</select>
</div>
<div class="form-group col-md-3">
<label class="small text-muted">{{ __('Grund') }}</label>
<select name="reason" class="form-control" onchange="this.form.submit()">
<option value="">{{ __('Filter aus') }}</option>
@foreach ($reasonOptions as $reason)
<option value="{{ $reason }}" @selected($filters['reason'] === $reason)>{{ $reason }}</option>
@endforeach
</select>
</div>
<div class="form-group col-md-2">
<label class="small text-muted">{{ __('Monat') }}</label>
<select name="month" class="form-control" onchange="this.form.submit()">
<option value="">{{ __('Alle') }}</option>
@foreach ($months as $num => $label)
<option value="{{ $num }}" @selected($filters['month'] === $num)>{{ $label }}</option>
@endforeach
</select>
</div>
<div class="form-group col-md-2">
<label class="small text-muted">{{ __('Jahr') }}</label>
<select name="year" class="form-control" onchange="this.form.submit()">
<option value="">{{ __('Alle') }}</option>
@foreach ($years as $y)
<option value="{{ $y }}" @selected($filters['year'] === $y)>{{ $y }}</option>
@endforeach
</select>
</div>
</div>
@if ($filters['product_id'] || $filters['direction'] || $filters['reason'] || $filters['month'] || $filters['year'])
<a href="{{ route('admin.inventory.product-stock.history') }}" class="btn btn-sm btn-outline-secondary mb-3">{{ __('Filter zurücksetzen') }}</a>
@endif
</form>
</div>
<div class="card-datatable table-responsive">
<table class="table table-striped wawi-table mb-0">
<thead>
<tr>
<th>{{ __('Produkt / Material') }}</th>
<th>{{ __('Art') }}</th>
<th class="text-right">{{ __('Stückzahl') }}</th>
<th>{{ __('Datum') }}</th>
<th>{{ __('Grund') }}</th>
<th>{{ __('Hinweis') }}</th>
</tr>
</thead>
<tbody>
@forelse ($movements as $movement)
<tr>
<td>{{ $movement->product?->name ?? '—' }}</td>
<td>
@if ($movement->isIn())
<span class="text-success">{{ __('Eingang') }}</span>
@else
<span class="text-danger">{{ __('Ausgang') }}</span>
@endif
</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($movement->quantity, 0) }} {{ __('Stück') }}</td>
<td>{{ $movement->created_at?->translatedFormat('l, d.m.Y') }}</td>
<td>{{ $movement->reason ?: '—' }}</td>
<td class="text-muted">
{{ $movement->note }}
@if ($movement->user)
<span class="d-block small">{{ __('Mitarbeiter') }}: {{ $movement->user->getFullName(false) ?: $movement->user->email }}</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="6" class="text-center text-muted py-4">{{ __('Keine Bewegungen gefunden.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endsection

View file

@ -0,0 +1,144 @@
@extends('layouts.layout-2')
@section('content')
@include('admin.inventory.partials.table-actions-style')
<div class="card">
<div class="card-header">
<div class="d-flex flex-wrap justify-content-between align-items-center">
<h6 class="mb-0">{{ __('Produktbestand') }}</h6>
<label class="form-check d-inline-flex align-items-center mb-0">
<input type="checkbox" id="ps-only-critical" class="form-check-input mr-2">
<span>{{ __('nur kritische anzeigen') }}</span>
</label>
</div>
<div class="form-group row mt-3 mb-0 align-items-center">
<label for="ps-search" class="col-sm-1 col-form-label">{{ __('Suchen') }}</label>
<div class="col-sm-5">
<input type="text" id="ps-search" class="form-control" autocomplete="off">
</div>
</div>
</div>
<div class="card-datatable table-responsive">
<table class="table table-striped wawi-table mb-0" id="ps-table">
<thead>
<tr>
<th style="width:4rem"></th>
<th>{{ __('Name') }}</th>
<th class="text-right">{{ __('Bestand') }}</th>
<th style="width:18rem">{{ __('Aktion') }}</th>
</tr>
</thead>
<tbody>
@forelse($rows as $row)
@php
$product = $row['product'];
$rowClass = $row['status'] === 'critical' ? 'table-danger' : ($row['status'] === 'warning' ? 'table-warning' : '');
$stockClass = $row['status'] === 'critical' ? 'text-danger font-weight-bold' : ($row['status'] === 'warning' ? 'text-warning font-weight-bold' : '');
@endphp
<tr class="ps-row {{ $rowClass }}" data-name="{{ Str::lower($product->name) }} {{ Str::lower($product->number ?? '') }}" data-status="{{ $row['status'] }}">
<td>
@if ($product->images->count())
<img class="img-fluid" alt="" style="max-height: 42px"
src="{{ route('product_image', [$product->images->first()->slug]) }}">
@endif
</td>
<td>{{ $product->name }}</td>
<td class="text-right {{ $stockClass }}">{{ \App\Services\Util::formatNumber($row['stock'], 0) }} {{ __('Stück') }}</td>
<td>
@if (Auth::user()->isAdmin())
<button type="button" class="btn icon-btn btn-sm btn-success js-ps-move"
data-product="{{ $product->id }}" data-name="{{ $product->name }}" data-direction="in"
title="{{ __('Eingang buchen') }}"><span class="fas fa-plus"></span></button>
<button type="button" class="btn icon-btn btn-sm btn-outline-danger js-ps-move"
data-product="{{ $product->id }}" data-name="{{ $product->name }}" data-direction="out"
title="{{ __('Ausgang buchen') }}"><span class="fas fa-minus"></span></button>
@endif
<a href="{{ route('admin.inventory.productions.create', ['product_id' => $product->id]) }}"
class="btn btn-sm btn-dark">{{ __('Produzieren') }}</a>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="text-center text-muted py-4">{{ __('Keine Produkte vorhanden.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@if (Auth::user()->isAdmin())
<div class="modal fade" id="ps-move-modal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog" role="document">
<form method="post" id="ps-move-form">
@csrf
<input type="hidden" name="direction" id="ps-move-direction">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ps-move-title">{{ __('Bestandsbewegung') }}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
</div>
<div class="modal-body">
<p class="text-muted mb-3" id="ps-move-product"></p>
<div class="form-group">
<label for="ps-move-quantity">{{ __('Stückzahl') }} <span class="text-danger">*</span></label>
<input type="number" min="1" step="1" name="quantity" id="ps-move-quantity" class="form-control" required>
</div>
<div class="form-group">
<label for="ps-move-reason">{{ __('Grund') }} <span class="text-danger">*</span></label>
<select name="reason" id="ps-move-reason" class="form-control" required>
@foreach ($reasons as $reason)
<option value="{{ $reason }}">{{ $reason }}</option>
@endforeach
</select>
</div>
<div class="form-group mb-0">
<label for="ps-move-note">{{ __('Hinweis') }}</label>
<input type="text" name="note" id="ps-move-note" class="form-control" maxlength="255" autocomplete="off">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">{{ __('Abbrechen') }}</button>
<button type="submit" class="btn btn-primary">{{ __('Buchen') }}</button>
</div>
</div>
</form>
</div>
</div>
@endif
<script>
$(function () {
var $rows = $('#ps-table tbody tr.ps-row');
function applyFilter() {
var term = ($('#ps-search').val() || '').toLowerCase().trim();
var onlyCritical = $('#ps-only-critical').is(':checked');
$rows.each(function () {
var $row = $(this);
var matchesTerm = term === '' || ($row.data('name') || '').toString().indexOf(term) !== -1;
var status = $row.data('status');
var matchesCritical = !onlyCritical || (status === 'critical' || status === 'warning');
$row.toggle(matchesTerm && matchesCritical);
});
}
$('#ps-search').on('keyup', applyFilter);
$('#ps-only-critical').on('change', applyFilter);
var moveTemplate = "{{ route('admin.inventory.product-stock.movement', ['product' => '__ID__']) }}";
$('.js-ps-move').on('click', function () {
var id = $(this).data('product');
var name = $(this).data('name');
var direction = $(this).data('direction');
$('#ps-move-form').attr('action', moveTemplate.replace('__ID__', id));
$('#ps-move-direction').val(direction);
$('#ps-move-product').text(name);
$('#ps-move-title').text(direction === 'in' ? '{{ __('Eingang buchen') }}' : '{{ __('Ausgang buchen') }}');
$('#ps-move-quantity').val('');
$('#ps-move-note').val('');
$('#ps-move-modal').modal('show');
});
});
</script>
@endsection

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

View file

@ -0,0 +1,156 @@
@extends('layouts.layout-2')
@section('content')
@include('admin.inventory.partials.table-actions-style')
<style>
#rms-table tbody tr.rms-row {
cursor: pointer;
}
#rms-table tbody tr.rms-row:hover {
background-color: rgba(0, 0, 0, 0.05);
}
#rms-table tbody tr.rms-row:hover .fa-edit {
color: #26b4ff !important;
}
</style>
<div class="card">
<div class="card-header">
<div class="d-flex flex-wrap justify-content-between align-items-center">
<h6 class="mb-0">{{ __('Rohstoffbestand') }}</h6>
<label class="form-check d-inline-flex align-items-center mb-0">
<input type="checkbox" id="rms-only-critical" class="form-check-input mr-2">
<span>{{ __('nur kritische anzeigen') }}</span>
</label>
</div>
<div class="form-group row mt-3 mb-0 align-items-center">
<label for="rms-search" class="col-sm-1 col-form-label">{{ __('Suchen') }}</label>
<div class="col-sm-5">
<input type="text" id="rms-search" class="form-control" autocomplete="off">
</div>
</div>
</div>
<div class="card-datatable table-responsive">
<table class="table table-striped wawi-table mb-0" id="rms-table">
<thead>
<tr>
<th>{{ __('Name') }}</th>
<th>{{ __('Qualität') }}</th>
<th class="text-right">{{ __('Bestand') }}</th>
<th class="text-right">{{ __('Offen bestellt') }}</th>
<th class="text-right">{{ __('Verbrauch / Tag') }}</th>
<th>{{ __('Voraussichtlich auf Null') }}</th>
<th class="text-right" style="min-width: 13rem;">
<select id="rms-horizon" class="form-control form-control-sm">
@foreach($horizonOptions as $days => $label)
<option value="{{ $days }}" @selected($days === $defaultHorizon)>{{ $label }}</option>
@endforeach
</select>
</th>
</tr>
</thead>
<tbody>
@forelse($rows as $row)
@php
$ingredient = $row['ingredient'];
$rowClass = $row['status'] === 'critical'
? 'table-danger'
: (in_array($row['status'], ['warning', 'critical_ordered'], true) ? 'table-warning' : '');
@endphp
<tr class="rms-row {{ $rowClass }}"
data-name="{{ Str::lower($ingredient->name) }} {{ Str::lower($ingredient->inci ?? '') }}"
data-status="{{ $row['status'] }}"
data-href="{{ route('admin.inventory.raw-material-stock.show', $ingredient) }}">
<td>
<i class="far fa-edit text-muted mr-2" title="{{ __('Bestellung öffnen') }}"></i>
{{ $ingredient->name }}
</td>
<td class="text-muted">{{ $ingredient->materialQuality?->name ?? '—' }}</td>
<td class="text-right {{ in_array($row['status'], ['critical', 'critical_ordered'], true) ? 'text-danger font-weight-bold' : '' }}">
{{ \App\Services\Util::formatNumber($row['remaining'], 0) }} g
@if($row['status'] === 'critical_ordered')
<span class="badge badge-info ml-1">{{ __('bestellt') }}</span>
@endif
</td>
<td class="text-right">
@if($row['open_order'] > 0)
{{ \App\Services\Util::formatNumber($row['open_order'], 0) }} g
@else
<span class="text-muted"></span>
@endif
</td>
<td class="text-right">
@if($row['daily'] !== null && $row['daily'] > 0)
{{ \App\Services\Util::formatNumber($row['daily'], 0) }} g
@else
<span class="text-muted"></span>
@endif
</td>
<td>
@if($row['expected_empty'] !== null)
{{ $row['expected_empty']->format('d.m.Y') }}
<span class="text-muted">({{ $row['days_until_empty'] }} {{ trans_choice('Tag|Tagen', $row['days_until_empty']) }})</span>
@else
<span class="text-muted"></span>
@endif
</td>
<td class="text-right rms-forecast" data-daily="{{ $row['daily'] !== null ? $row['daily'] : 0 }}">
@if($row['daily'] !== null && $row['daily'] > 0)
{{ \App\Services\Util::formatNumber($row['daily'] * $defaultHorizon, 0) }} g
@else
<span class="text-muted"></span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="7" class="text-center text-muted py-4">{{ __('Keine aktiven Rohstoffe vorhanden.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<script>
$(function () {
var $rows = $('#rms-table tbody tr.rms-row');
function applyFilter() {
var term = ($('#rms-search').val() || '').toLowerCase().trim();
var onlyCritical = $('#rms-only-critical').is(':checked');
$rows.each(function () {
var $row = $(this);
var matchesTerm = term === '' || ($row.data('name') || '').toString().indexOf(term) !== -1;
var status = $row.data('status');
var matchesCritical = !onlyCritical || (status === 'critical' || status === 'warning');
$row.toggle(matchesTerm && matchesCritical);
});
}
function applyForecast() {
var days = parseInt($('#rms-horizon').val(), 10) || 0;
$('.rms-forecast').each(function () {
var daily = parseFloat($(this).data('daily')) || 0;
if (daily > 0) {
var total = Math.round(daily * days);
$(this).text(total.toLocaleString('de-DE') + ' g');
} else {
$(this).html('<span class="text-muted">—</span>');
}
});
}
$('#rms-search').on('keyup', applyFilter);
$('#rms-only-critical').on('change', applyFilter);
$('#rms-horizon').on('change', applyForecast);
$rows.on('click', function () {
var href = $(this).data('href');
if (href) {
window.location = href;
}
});
});
</script>
@endsection

View file

@ -0,0 +1,256 @@
@extends('layouts.layout-2')
@section('content')
@include('admin.inventory.partials.table-actions-style')
@php
$statusBadge = match ($status) {
'critical' => '<span class="badge badge-danger">' . __('Kritisch') . '</span>',
'critical_ordered' => '<span class="badge badge-warning">' . __('Kritisch · bereits bestellt') . '</span>',
'warning' => '<span class="badge badge-warning">' . __('Bald nachbestellen') . '</span>',
default => '<span class="badge badge-success">' . __('Ausreichend') . '</span>',
};
@endphp
<div class="card mb-3">
<h6 class="card-header d-flex justify-content-between align-items-center">
<span>{{ __('Rohstoffbestellung') }}: {{ $ingredient->name }} {!! $statusBadge !!}</span>
<a href="{{ route('admin.inventory.raw-material-stock.index') }}" class="btn btn-sm btn-outline-secondary">{{ __('Zurück') }}</a>
</h6>
<div class="card-body">
<div class="row">
<div class="col-md-3 col-6 mb-2">
<div class="text-muted small">{{ __('Qualität') }}</div>
<div>{{ $ingredient->materialQuality?->name ?? '—' }}</div>
</div>
<div class="col-md-3 col-6 mb-2">
<div class="text-muted small">{{ __('Gesamtbestand') }}</div>
<div class="{{ in_array($status, ['critical', 'critical_ordered'], true) ? 'text-danger font-weight-bold' : 'font-weight-bold' }}">
{{ \App\Services\Util::formatNumber($remaining, 0) }} g
</div>
</div>
<div class="col-md-3 col-6 mb-2">
<div class="text-muted small">{{ __('Offen bestellt') }}</div>
<div>
@if($openTotal > 0)
{{ \App\Services\Util::formatNumber($openTotal, 0) }} g
@else
@endif
</div>
</div>
<div class="col-md-3 col-6 mb-2">
<div class="text-muted small">{{ __('Verbrauch / Tag') }}</div>
<div>
@if($daily !== null && $daily > 0)
{{ \App\Services\Util::formatNumber($daily, 0) }} g
@else
@endif
</div>
</div>
<div class="col-md-3 col-6 mb-2">
<div class="text-muted small">{{ __('Voraussichtlich auf Null') }}</div>
<div>
@if($expectedEmpty !== null)
{{ $expectedEmpty->format('d.m.Y') }}
<span class="text-muted">({{ $daysUntilEmpty }} {{ trans_choice('Tag|Tagen', $daysUntilEmpty) }})</span>
@else
@endif
</div>
</div>
</div>
@if($ingredient->min_stock_alert !== null)
<div class="text-muted small mt-2">
{{ __('Meldebestand') }}: {{ \App\Services\Util::formatNumber($ingredient->min_stock_alert, 0) }} g
</div>
@endif
</div>
</div>
<div class="card mb-3">
<h6 class="card-header">{{ __('Enthalten in') }}</h6>
<div class="card-datatable table-responsive">
<table class="table table-striped wawi-table mb-0">
<thead>
<tr>
<th>{{ __('Produkt') }}</th>
<th class="text-right">{{ __('Produktbestand') }}</th>
<th class="text-right">{{ __('Rezeptur-Anteil (g/Stück)') }}</th>
</tr>
</thead>
<tbody>
@forelse($ingredient->products as $product)
<tr>
<td>{{ $product->name }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($productStock[$product->id] ?? 0, 0) }} {{ __('Stück') }}</td>
<td class="text-right">
@if($product->pivot->gram !== null && $product->pivot->gram !== '')
{{ \App\Services\Util::formatNumber($product->pivot->gram, 2) }} g
@else
<span class="text-muted"></span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="3" class="text-center text-muted py-3">{{ __('Dieser Rohstoff wird in keiner aktiven Rezeptur verwendet.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="card mb-3">
<h6 class="card-header">{{ __('Lieferanten & Bestellung') }}</h6>
<div class="card-datatable table-responsive">
<table class="table table-striped wawi-table mb-0">
<thead>
<tr>
<th>{{ __('Lieferant') }}</th>
<th>{{ __('Lieferzeit') }}</th>
<th class="text-right">{{ __('Letzter Einkauf (Netto/kg)') }}</th>
<th class="text-right" style="width: 11rem;"></th>
</tr>
</thead>
<tbody>
@forelse($ingredient->suppliers as $supplier)
@php
$deliveryTime = $ingredient->delivery_time ?: $supplier->delivery_time;
$lastPrice = $lastPriceBySupplier[$supplier->id] ?? null;
$orderUrl = $supplier->order_url ?: $supplier->url;
$orderEmail = $supplier->order_email ?: $supplier->email;
@endphp
<tr>
<td>
{{ $supplier->name }}
@if($supplier->pivot->preferred)
<span class="badge badge-success ml-1">{{ __('bevorzugt') }}</span>
@endif
@if($supplier->pivot->supplier_sku)
<div class="text-muted small">{{ __('Art.-Nr.') }}: {{ $supplier->pivot->supplier_sku }}</div>
@endif
</td>
<td>{{ $deliveryTime ?: '—' }}</td>
<td class="text-right">
@if($lastPrice !== null)
{{ \App\Services\Util::formatNumber($lastPrice, 2) }}
@else
<span class="text-muted"></span>
@endif
</td>
<td class="text-right">
@if($supplier->order_method === 'online_shop' && $orderUrl)
<a href="{{ $orderUrl }}" target="_blank" rel="noopener" class="btn btn-sm btn-dark">{{ __('Zum Shop') }}</a>
@elseif($orderEmail)
<a href="mailto:{{ $orderEmail }}?subject={{ rawurlencode(__('Bestellung') . ': ' . $ingredient->name) }}" class="btn btn-sm btn-dark">{{ __('Per Mail') }}</a>
@elseif($orderUrl)
<a href="{{ $orderUrl }}" target="_blank" rel="noopener" class="btn btn-sm btn-dark">{{ __('Zum Shop') }}</a>
@else
<span class="text-muted"></span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="4" class="text-center text-muted py-3">{{ __('Diesem Rohstoff ist kein Lieferant zugeordnet.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if(Auth::user()->isAdmin())
<div class="card-footer">
<a href="{{ route('admin.inventory.stock-entries.create', ['ingredient_id' => $ingredient->id]) }}" class="btn btn-sm btn-primary">{{ __('Einkauf erfassen') }}</a>
<a href="{{ route('admin.inventory.stock-disposals.create', ['ingredient_id' => $ingredient->id]) }}" class="btn btn-sm btn-outline-danger">{{ __('Ausschuss erfassen') }}</a>
</div>
@endif
</div>
<div class="card mb-3">
<h6 class="card-header">{{ __('Offene Bestellungen / unterwegs') }}</h6>
<div class="card-datatable table-responsive">
<table class="table table-striped wawi-table mb-0">
<thead>
<tr>
<th>{{ __('Bestellt am') }}</th>
<th>{{ __('Lieferant') }}</th>
<th>{{ __('Lagerort') }}</th>
<th class="text-right">{{ __('Bestellte Menge') }}</th>
<th class="text-right" style="width: 8rem;"></th>
</tr>
</thead>
<tbody>
@forelse($openOrders as $order)
<tr>
<td>{{ $order->ordered_at?->format('d.m.Y') ?? '—' }}</td>
<td>{{ $order->supplier?->name ?? '—' }}</td>
<td>{{ $order->location?->name ?? '—' }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($order->ordered_quantity, 0) }} g</td>
<td class="text-right">
<a href="{{ route('admin.inventory.stock-entries.show', $order) }}" class="btn icon-btn btn-sm btn-primary" title="{{ __('Wareneingang buchen') }}">
<span class="far fa-eye"></span>
</a>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center text-muted py-3">{{ __('Keine offenen Bestellungen.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($openOrders->isNotEmpty())
<div class="card-footer text-muted small">
{{ __('Offene Bestellungen zählen erst nach gebuchtem Wareneingang zum Bestand.') }}
</div>
@endif
</div>
<div class="card mb-3">
<h6 class="card-header">{{ __('Verfügbare Chargen') }}</h6>
<div class="card-datatable table-responsive">
<table class="table table-striped wawi-table mb-0">
<thead>
<tr>
<th>{{ __('Charge') }}</th>
<th>{{ __('Lieferant') }}</th>
<th>{{ __('Lagerort') }}</th>
<th>{{ __('MHD') }}</th>
<th class="text-right">{{ __('Restbestand') }}</th>
</tr>
</thead>
<tbody>
@forelse($charges as $charge)
<tr>
<td>{{ $charge->batch_number ?: '#' . $charge->id }}</td>
<td>{{ $charge->supplier?->name ?? '—' }}</td>
<td>{{ $charge->location?->name ?? '—' }}</td>
<td>{{ $charge->best_before?->format('d.m.Y') ?? '—' }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($charge->getAttribute('remaining_quantity'), 0) }} g</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center text-muted py-3">{{ __('Kein Restbestand vorhanden.') }}</td>
</tr>
@endforelse
</tbody>
@if($charges->isNotEmpty() && count($remainingByLocation) > 0)
<tfoot>
@foreach($locations as $location)
@if(isset($remainingByLocation[$location->id]) && $remainingByLocation[$location->id] > 0)
<tr>
<td colspan="4" class="text-right text-muted">{{ __('Bestand') }} {{ $location->name }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($remainingByLocation[$location->id], 0) }} g</td>
</tr>
@endif
@endforeach
</tfoot>
@endif
</table>
</div>
</div>
@endsection

View file

@ -0,0 +1,194 @@
@extends('layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-2 mb-2">{{ __('Ausgang / Ausschuss erfassen') }}</h4>
<div class="card">
<div class="card-body">
<p class="text-muted small">{{ __('Reduziert den Bestand des gewählten Rohstoffs bzw. Verpackungsartikels. Der Grund ist Pflicht und erscheint in der Ausgangsliste.') }}</p>
<form method="post" action="{{ route('admin.inventory.stock-disposals.store') }}">
@csrf
<div class="form-group">
<label for="disposal_type">{{ __('Art') }} <span class="text-danger">*</span></label>
<select name="disposal_type" id="disposal_type" class="form-control" required>
<option value="ingredient" @selected(old('disposal_type', $prefill['disposal_type']) === 'ingredient')>{{ __('Rohstoff') }}</option>
<option value="packaging" @selected(old('disposal_type', $prefill['disposal_type']) === 'packaging')>{{ __('Verpackung') }}</option>
</select>
</div>
<div id="disposal-ingredient-block" class="form-group" style="display:none;">
<label for="ingredient_id">{{ __('Rohstoff') }} <span class="text-danger">*</span></label>
<div class="light-style">
<select name="ingredient_id" id="ingredient_id" class="w-100"
data-search-url="{{ route('admin.inventory.api.ingredients.search') }}"
data-charges-url="{{ route('admin.inventory.api.disposals.ingredient-charges', ['ingredient' => '__ID__']) }}">
@if ($prefill['ingredient_id'])
<option value="{{ $prefill['ingredient_id'] }}" selected>{{ $prefill['ingredient_label'] }}</option>
@elseif(old('ingredient_id'))
<option value="{{ old('ingredient_id') }}" selected>{{ old('ingredient_id') }}</option>
@endif
</select>
</div>
@error('ingredient_id')
<div class="text-danger small">{{ $message }}</div>
@enderror
</div>
<div id="disposal-charge-block" class="form-group" style="display:none;">
<label for="stock_entry_id">{{ __('Charge') }} <span class="text-muted small">({{ __('optional') }})</span></label>
<select name="stock_entry_id" id="stock_entry_id" class="form-control">
<option value="">{{ __('— keine bestimmte Charge —') }}</option>
</select>
<small class="text-muted">{{ __('Bei Auswahl wird der Lagerort automatisch gesetzt.') }}</small>
</div>
<div id="disposal-packaging-block" class="form-group" style="display:none;">
<label for="packaging_item_id">{{ __('Verpackungsartikel') }} <span class="text-danger">*</span></label>
<div class="light-style">
<select name="packaging_item_id" id="packaging_item_id" class="w-100"
data-search-url="{{ route('admin.inventory.api.packaging-items.search') }}">
@if (old('packaging_item_id'))
<option value="{{ old('packaging_item_id') }}" selected>{{ old('packaging_item_id') }}</option>
@endif
</select>
</div>
@error('packaging_item_id')
<div class="text-danger small">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="location_id">{{ __('Lagerort') }} <span class="text-danger">*</span></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((string) old('location_id') === (string) $loc->id)>{{ $loc->name }}</option>
@endforeach
</select>
@error('location_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="quantity">{{ __('Menge') }} <span class="text-danger">*</span></label>
<input type="text" name="quantity" id="quantity" autocomplete="off"
class="form-control @error('quantity') is-invalid @enderror" value="{{ old('quantity') }}" required>
<small class="text-muted" id="quantity-hint">{{ __('Bei Rohstoff in Gramm, bei Verpackung in Stück.') }}</small>
@error('quantity')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="reason">{{ __('Grund') }} <span class="text-danger">*</span></label>
<select name="reason" id="reason" class="form-control @error('reason') is-invalid @enderror" required>
@foreach ($reasons as $reason)
<option value="{{ $reason }}" @selected(old('reason') === $reason)>{{ $reason }}</option>
@endforeach
</select>
@error('reason')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="note">{{ __('Hinweis') }}</label>
<input type="text" name="note" id="note" maxlength="255" autocomplete="off"
class="form-control @error('note') is-invalid @enderror" value="{{ old('note') }}">
@error('note')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="disposed_at">{{ __('Datum') }} <span class="text-danger">*</span></label>
<input type="text" name="disposed_at" id="disposed_at" autocomplete="off"
class="form-control datepicker-base @error('disposed_at') is-invalid @enderror"
value="{{ old('disposed_at', now()->format('d.m.Y')) }}" required>
@error('disposed_at')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<button type="submit" class="btn btn-primary">{{ __('Buchen') }}</button>
<a href="{{ route('admin.inventory.stock-disposals.index') }}" class="btn btn-outline-secondary">{{ __('Zurück') }}</a>
</form>
</div>
</div>
@endsection
@section('scripts')
<script>
(function ($) {
function toggleBlocks() {
var isIng = $('#disposal_type').val() === 'ingredient';
$('#disposal-ingredient-block').toggle(isIng);
$('#disposal-charge-block').toggle(isIng);
$('#disposal-packaging-block').toggle(!isIng);
$('#quantity-hint').text(isIng
? '{{ __('Menge in Gramm.') }}'
: '{{ __('Menge in Stück.') }}');
}
function initSearchSelect2(id, placeholder) {
var $el = $('#' + id);
if ($el.data('select2')) {
$el.select2('destroy');
}
$el.select2({
theme: 'default',
width: '100%',
placeholder: placeholder,
allowClear: true,
ajax: {
url: $el.data('search-url'),
dataType: 'json',
delay: 250,
data: function (params) { return {q: params.term || ''}; },
processResults: function (data) { return {results: data.results || []}; },
cache: true
},
minimumInputLength: 1
});
}
function loadCharges(ingredientId) {
var $charge = $('#stock_entry_id');
$charge.empty().append($('<option>').val('').text('{{ __(' keine bestimmte Charge ') }}'));
if (!ingredientId) {
return;
}
var url = $('#ingredient_id').data('charges-url').replace('__ID__', ingredientId);
$.getJSON(url, function (data) {
(data.charges || []).forEach(function (c) {
$charge.append($('<option>').val(c.id).text(c.text).attr('data-location', c.location_id));
});
});
}
$(document).ready(function () {
toggleBlocks();
initSearchSelect2('ingredient_id', '{{ __('Rohstoff suchen…') }}');
initSearchSelect2('packaging_item_id', '{{ __('Verpackungsartikel suchen…') }}');
$('#disposal_type').on('change', toggleBlocks);
$('#ingredient_id').on('change', function () {
loadCharges($(this).val());
});
$('#stock_entry_id').on('change', function () {
var loc = $(this).find('option:selected').data('location');
if (loc) {
$('#location_id').val(String(loc));
}
});
@if ($prefill['ingredient_id'])
loadCharges('{{ $prefill['ingredient_id'] }}');
@endif
});
})(jQuery);
</script>
@endsection

View file

@ -0,0 +1,76 @@
@extends('layouts.layout-2')
@section('content')
@include('admin.inventory.partials.table-actions-style')
<div class="card">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center">
<h6 class="mb-0">{{ __('Ausgang / Ausschuss') }}</h6>
<div>
<form method="get" class="d-inline-block mr-2">
<select name="type" class="form-control form-control-sm d-inline-block" style="width:auto"
onchange="this.form.submit()">
<option value="">{{ __('Alle Arten') }}</option>
<option value="ingredient" @selected($typeFilter === 'ingredient')>{{ __('Rohstoff') }}</option>
<option value="packaging" @selected($typeFilter === 'packaging')>{{ __('Verpackung') }}</option>
</select>
</form>
@if (Auth::user()->isAdmin())
<a href="{{ route('admin.inventory.stock-disposals.create') }}" class="btn btn-sm btn-primary">
{{ __('Ausschuss erfassen') }}
</a>
@endif
</div>
</div>
<div class="card-datatable table-responsive">
<table class="table table-striped wawi-table mb-0">
<thead>
<tr>
<th>{{ __('Datum') }}</th>
<th>{{ __('Art') }}</th>
<th>{{ __('Artikel') }}</th>
<th>{{ __('Charge') }}</th>
<th>{{ __('Lagerort') }}</th>
<th class="text-right">{{ __('Menge') }}</th>
<th>{{ __('Grund') }}</th>
<th>{{ __('Hinweis') }}</th>
<th>{{ __('Mitarbeiter') }}</th>
</tr>
</thead>
<tbody>
@forelse($values as $disposal)
<tr>
<td>{{ $disposal->disposed_at?->format('d.m.Y') }}</td>
<td>
@if ($disposal->isIngredient())
<span class="badge badge-info">{{ __('Rohstoff') }}</span>
@else
<span class="badge badge-secondary">{{ __('Verpackung') }}</span>
@endif
</td>
<td>{{ $disposal->articleName() }}</td>
<td>
@if ($disposal->stockEntry)
{{ $disposal->stockEntry->batch_number ?: '#'.$disposal->stockEntry->id }}
@else
<span class="text-muted"></span>
@endif
</td>
<td>{{ $disposal->location?->name ?? '—' }}</td>
<td class="text-right text-danger font-weight-bold">
{{ \App\Services\Util::formatNumber($disposal->quantity, $disposal->unit === 'piece' ? 0 : 2) }}
{{ $disposal->unit === 'piece' ? __('Stück') : 'g' }}
</td>
<td>{{ $disposal->reason }}</td>
<td class="text-muted">{{ $disposal->note }}</td>
<td>{{ $disposal->user?->getFullName(false) ?: ($disposal->user?->email ?? '—') }}</td>
</tr>
@empty
<tr>
<td colspan="9" class="text-center text-muted py-4">{{ __('Noch keine Ausgänge erfasst.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endsection

View file

@ -95,9 +95,9 @@
<div class="form-group">
<label for="ordered_at">{{ __('Bestelldatum') }} <span class="text-danger">*</span></label>
<input type="date" name="ordered_at" id="ordered_at"
class="form-control @error('ordered_at') is-invalid @enderror"
value="{{ old('ordered_at', $model->ordered_at ? $model->ordered_at->format('Y-m-d') : '') }}" required>
<input type="text" name="ordered_at" id="ordered_at" autocomplete="off"
class="form-control datepicker-base @error('ordered_at') is-invalid @enderror"
value="{{ old('ordered_at', $model->ordered_at ? $model->ordered_at->format('d.m.Y') : '') }}" required>
@error('ordered_at')
<div class="invalid-feedback">{{ $message }}</div>
@enderror

View file

@ -137,9 +137,9 @@
<div class="form-group">
<label for="received_at">{{ __('Eingangsdatum') }} <span class="text-danger">*</span></label>
<input type="date" name="received_at" id="received_at" required
class="form-control @error('received_at') is-invalid @enderror"
value="{{ old('received_at', now()->toDateString()) }}">
<input type="text" name="received_at" id="received_at" required autocomplete="off"
class="form-control datepicker-base @error('received_at') is-invalid @enderror"
value="{{ old('received_at', now()->format('d.m.Y')) }}">
@error('received_at')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@ -175,8 +175,8 @@
<div class="form-group">
<label for="best_before">{{ __('Mindesthaltbarkeit') }} <span class="text-danger">*</span></label>
<input type="date" name="best_before" id="best_before"
class="form-control @error('best_before') is-invalid @enderror"
<input type="text" name="best_before" id="best_before" autocomplete="off"
class="form-control datepicker-base @error('best_before') is-invalid @enderror"
value="{{ old('best_before') }}">
@error('best_before')
<div class="invalid-feedback">{{ $message }}</div>