gruene-seele/resources/views/admin/inventory/product-stock/index.blade.php
Kevin Adametz 30d4e28696 Warenwirtschaft: AP-51 — Lagerplatz-Felder für Produkte & Verpackung
Lagerplatz-Zuordnung (Raum/Regal/Fach) gab es bisher nur bei Rohstoffen. Jetzt
dieselbe Storage-Hierarchie (StorageRoom/Shelf/Slot) auch für Produkte und
Verpackungsartikel, inkl. Anzeige an den passenden Stellen.

- Migration: storage_room_id/shelf/slot an products + packaging_items
  (nullable, FK constrained, nullOnDelete).
- Models Product + PackagingItem: fillable + storageRoom/Shelf/Slot-Relationen +
  storageLabel() („Raum 1 | Regal 3 | A").
- Produktformular (Inventar-Abschnitt) + Verpackungsformular: Auswahl-Zeile
  Raum/Regal/Fach; Optionen kommen aktiv/sortiert aus den zentral pflegbaren
  Bausteinen (Einstellungen → Allgemein).
- Speichern: ProductRepository normalisiert leere Auswahl -> null (FK);
  PackagingItem-Requests + Repository um die drei Felder erweitert.
- Anzeige: Produktbestand (Sub-Zeile mit Marker), Produktverpackung-Übersicht
  (neue Spalte „Lagerplatz").
- DeletionGuard: Lagerbaustein ist nicht löschbar, solange Produkte/Verpackung
  darauf verweisen (analog Rohstoffe).

Tests: 4 neue (Produkt speichern + leer->null, Verpackung speichern + Anzeige,
Löschsperre, freier Baustein löschbar). Migration auf Dev-/Live-DB gelaufen.
Suite 331 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 09:46:40 +00:00

286 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
@if ($product->storageLabel())
<div class="wawi-item-sub text-muted"><span class="fas fa-map-marker-alt mr-1"></span>{{ $product->storageLabel() }}</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