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:
parent
78679e0c55
commit
3ee2d756e9
63 changed files with 5968 additions and 901 deletions
44
resources/views/admin/inventory/notices/index.blade.php
Normal file
44
resources/views/admin/inventory/notices/index.blade.php
Normal 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
|
||||
|
|
@ -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
|
||||
110
resources/views/admin/inventory/product-stock/history.blade.php
Normal file
110
resources/views/admin/inventory/product-stock/history.blade.php
Normal 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
|
||||
144
resources/views/admin/inventory/product-stock/index.blade.php
Normal file
144
resources/views/admin/inventory/product-stock/index.blade.php
Normal 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">×</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
|
||||
|
|
@ -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>
|
||||
213
resources/views/admin/inventory/productions/_scripts.blade.php
Normal file
213
resources/views/admin/inventory/productions/_scripts.blade.php
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
194
resources/views/admin/inventory/stock-disposals/create.blade.php
Normal file
194
resources/views/admin/inventory/stock-disposals/create.blade.php
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue