gruene-seele/resources/views/admin/inventory/product-stock/index.blade.php
Kevin Adametz 190713c21b Warenwirtschaft: AP-48 — Produktbestand-Buchungsgründe pflegbar, getrennt Ein/Aus
Kundenentscheid 26.08. (D-5): Eingang und Ausgang haben eigene, pflegbare
Gründe-Listen statt einer festen Liste im Controller.

- Neues Modell/Tabelle stock_movement_reasons (label, direction in/out, pos,
  active, SoftDeletes) inkl. Seed der bisherigen Gründe sinnvoll auf Ein-/Ausgang
  aufgeteilt.
- CRUD wie DisposalReason (SettingCrudController + FormRequest + Formular mit
  Richtungs-Auswahl); zwei Listen (Eingangs-/Ausgangs-Gründe) auf der Seite
  Einstellungen → Allgemeines, „Neu anlegen" mit vorbelegter Richtung.
- Produktbestand-Maske: das Grund-Dropdown wird jetzt je nach Eingang/Ausgang
  per JS aus den passenden, gepflegten Gründen befüllt (manualReasons entfernt).
- DeletionGuard: StockMovementReason immer löschbar.

Migration create_stock_movement_reasons auf Dev-/Live-DB. 4 Tests, Suite 326 grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-28 09:22:32 +00:00

283 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@extends('layouts.layout-2')
@php
$total = $rows->count();
$criticalCount = $rows->where('status', 'critical')->count();
$warningCount = $rows->where('status', 'warning')->count();
$okCount = $total - $criticalCount - $warningCount;
@endphp
@section('content')
@include('admin.inventory.partials.wawi-ui')
<div class="wawi-page">
<div class="wawi-page-head">
<div>
<h1 class="wawi-page-head__title">{{ __('Produktbestand') }}</h1>
<p class="wawi-page-head__subtitle">{{ __('Aktueller Lagerbestand aller aktiven Produkte') }}</p>
</div>
<div class="wawi-page-head__actions">
<a href="{{ route('admin.inventory.product-stock.history') }}" class="btn btn-outline-secondary btn-sm">
<span class="far fa-clock mr-1"></span>{{ __('Historie') }}
</a>
</div>
</div>
<div class="wawi-stats">
<div class="wawi-stat is-clickable" data-filter="all">
<div class="wawi-stat__icon"><span class="fas fa-boxes-stacked"></span></div>
<div class="wawi-stat__body">
<div class="wawi-stat__value">{{ $total }}</div>
<div class="wawi-stat__label">{{ __('Produkte') }}</div>
</div>
</div>
<div class="wawi-stat wawi-stat--ok is-clickable" data-filter="ok">
<div class="wawi-stat__icon"><span class="fas fa-circle-check"></span></div>
<div class="wawi-stat__body">
<div class="wawi-stat__value">{{ $okCount }}</div>
<div class="wawi-stat__label">{{ __('Bestand OK') }}</div>
</div>
</div>
<div class="wawi-stat wawi-stat--warning is-clickable" data-filter="warning">
<div class="wawi-stat__icon"><span class="fas fa-triangle-exclamation"></span></div>
<div class="wawi-stat__body">
<div class="wawi-stat__value">{{ $warningCount }}</div>
<div class="wawi-stat__label">{{ __('Niedrig') }}</div>
</div>
</div>
<div class="wawi-stat wawi-stat--danger is-clickable" data-filter="critical">
<div class="wawi-stat__icon"><span class="fas fa-circle-exclamation"></span></div>
<div class="wawi-stat__body">
<div class="wawi-stat__value">{{ $criticalCount }}</div>
<div class="wawi-stat__label">{{ __('Kritisch') }}</div>
</div>
</div>
</div>
<div class="wawi-card">
<div class="wawi-toolbar">
<div class="wawi-search">
<span class="fas fa-search"></span>
<input type="text" id="ps-search" class="form-control" autocomplete="off"
placeholder="{{ __('Produkt suchen …') }}">
</div>
<div class="wawi-toolbar__spacer"></div>
<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="table-responsive">
<table class="table wawi-table" id="ps-table">
<thead>
<tr>
<th style="width:3.5rem"></th>
<th>{{ __('Name') }}</th>
<th class="text-right">{{ __('Bestand') }}</th>
<th class="text-right" title="{{ __('Durchschnitt der letzten 6 Monate') }}">{{ __('Verbrauch/Monat') }}</th>
<th id="ps-sort-status" style="cursor:pointer" title="{{ __('Nach Dringlichkeit sortieren') }}">
{{ __('Status') }} <span class="fas fa-sort text-muted small"></span>
</th>
<th class="text-right" style="width:16rem">{{ __('Aktion') }}</th>
</tr>
</thead>
<tbody>
@forelse($rows as $row)
@php
$product = $row['product'];
$rowClass = $row['status'] === 'critical' ? 'is-danger' : ($row['status'] === 'warning' ? 'is-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'] }}" data-rank="{{ ['critical' => 0, 'warning' => 1, 'ok' => 2][$row['status']] }}">
<td>
@if ($product->images->count())
<img class="wawi-thumb" alt=""
src="{{ route('product_image', [$product->images->first()->slug]) }}">
@else
<span class="wawi-thumb wawi-thumb--empty"><span class="far fa-image"></span></span>
@endif
</td>
<td>
<div class="wawi-item-name">{{ $product->name }}</div>
@if (!empty($product->number))
<div class="wawi-item-sub">{{ $product->number }}</div>
@endif
</td>
<td class="text-right {{ $stockClass }}">
{{ \App\Services\Util::formatNumber($row['stock'], 0) }} <span class="text-muted">{{ __('Stück') }}</span>
</td>
<td class="text-right">
@if ($row['monthly_consumption'] > 0)
{{ \App\Services\Util::formatNumber($row['monthly_consumption'], 1) }} <span class="text-muted">{{ __('Stück') }}</span>
@else
<span class="text-muted">–</span>
@endif
</td>
<td>
@if ($row['status'] === 'critical')
<span class="wawi-pill wawi-pill--danger">{{ __('Kritisch') }}</span>
@elseif ($row['status'] === 'warning')
<span class="wawi-pill wawi-pill--warning">{{ __('Niedrig') }}</span>
@else
<span class="wawi-pill wawi-pill--ok">{{ __('OK') }}</span>
@endif
</td>
<td class="text-right">
<div class="wawi-actions">
@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-stock="{{ (int) $row['stock'] }}" 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-stock="{{ (int) $row['stock'] }}" data-direction="out"
title="{{ __('Ausgang buchen') }}"><span class="fas fa-minus"></span></button>
<a href="{{ route('admin.inventory.productions.create', ['product_id' => $product->id]) }}"
class="btn btn-sm btn-outline-secondary">
<span class="fas fa-flask mr-1"></span>{{ __('Produzieren') }}</a>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6">
<div class="wawi-empty">
<div><span class="fas fa-boxes-stacked"></span></div>
{{ __('Keine Produkte vorhanden.') }}
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</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-1" id="ps-move-product"></p>
{{-- Audit N-10: aktuellen Bestand zeigen, Minus-Buchungen bewusst bestätigen lassen. --}}
<p class="small mb-3">{{ __('Aktueller Bestand') }}: <strong id="ps-move-stock"></strong> {{ __('Stück') }}</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>
{{-- AP-48: Optionen werden je Richtung (Eingang/Ausgang) per JS gefüllt --}}
<select name="reason" id="ps-move-reason" class="form-control" required></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');
var statusFilter = null; // null = alle; sonst 'ok' | 'warning' | 'critical'
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');
var matchesStatus = statusFilter === null || status === statusFilter;
$row.toggle(matchesTerm && matchesCritical && matchesStatus);
});
}
$('#ps-search').on('keyup', applyFilter);
$('#ps-only-critical').on('change', applyFilter);
// AP-22: alle vier Kacheln filtern (Produkte = Filter aufheben, OK/Niedrig/Kritisch = nur dieser Status).
$('.wawi-stat.is-clickable').on('click', function () {
var filter = $(this).data('filter');
var $this = $(this);
var wasActive = $this.hasClass('is-active');
$('.wawi-stat').removeClass('is-active');
$('#ps-only-critical').prop('checked', false);
if (filter === 'all' || wasActive) {
statusFilter = null;
} else {
statusFilter = filter;
$this.addClass('is-active');
}
applyFilter();
});
// AP-22: Klick auf „Status" sortiert nach Dringlichkeit (Default bereits dringlichste oben; Klick toggelt).
var statusSortAsc = true; // Server liefert bereits kritisch → ok
$('#ps-sort-status').on('click', function () {
statusSortAsc = !statusSortAsc;
var sorted = $rows.get().sort(function (a, b) {
var diff = ($(a).data('rank') - $(b).data('rank'));
return statusSortAsc ? diff : -diff;
});
$('#ps-table tbody').append(sorted);
});
var moveTemplate = "{{ route('admin.inventory.product-stock.movement', ['product' => '__ID__']) }}";
var moveStock = 0;
// AP-48: getrennte, pflegbare Buchungsgründe je Richtung.
var psReasons = {in: @json($reasonsIn), out: @json($reasonsOut)};
function psPopulateReasons(direction) {
var $sel = $('#ps-move-reason').empty();
(psReasons[direction] || []).forEach(function (r) {
$sel.append($('<option></option>').val(r).text(r));
});
}
$('.js-ps-move').on('click', function () {
var id = $(this).data('product');
var name = $(this).data('name');
var direction = $(this).data('direction');
moveStock = parseInt($(this).data('stock'), 10) || 0;
$('#ps-move-form').attr('action', moveTemplate.replace('__ID__', id));
$('#ps-move-direction').val(direction);
psPopulateReasons(direction);
$('#ps-move-product').text(name);
$('#ps-move-stock').text(moveStock);
$('#ps-move-title').text(direction === 'in' ? '{{ __('Eingang buchen') }}' : '{{ __('Ausgang buchen') }}');
$('#ps-move-quantity').val('');
$('#ps-move-note').val('');
$('#ps-move-modal').modal('show');
});
// Audit N-10: Ausgang über Bestand => Bestätigung (Inventur-Korrekturen bleiben möglich).
$('#ps-move-form').on('submit', function (e) {
var qty = parseInt($('#ps-move-quantity').val(), 10) || 0;
if ($('#ps-move-direction').val() === 'out' && qty > moveStock) {
var after = moveStock - qty;
if (!confirm('{{ __('Der Ausgang übersteigt den aktuellen Bestand. Der Bestand fällt auf') }} ' + after + ' {{ __('Stück. Fortfahren?') }}')) {
e.preventDefault();
}
}
});
});
</script>
@endsection