Neue Anforderungen (docs/) interpretiert und als Entwicklungsplan V5.0 (AP-20 bis AP-28) aufgenommen; erste drei Pakete umgesetzt: AP-26 Ausschuss-Gründe konfigurierbar: - Stammdaten-Tabelle disposal_reasons + CRUD unter Einstellungen → Allgemein - StockDisposalController liest aktive DB-Gründe statt hartkodierter Liste - Seeder übernimmt die bisherigen 6 Gründe idempotent AP-25 Lieferbestand — Datum statt Tage: - "Nicht vorrätig" wird über Datepicker "Wieder lieferbar ab" gepflegt; Resttage-Hinweis zählt täglich automatisch herunter - Interne Bestellliste wieder kaufbar: Hinweis erscheint zusätzlich zu den Mengen-Buttons (VP entscheidet selbst) AP-22 Produktbestand-Erweiterungen: - Default-Sortierung nach Dringlichkeit, Status-Kopf toggelt - Alle vier Status-Kacheln als Filter klickbar - Neue Spalte "Verbrauch/Monat" (Ø Abgänge der letzten 6 Monate) - Produkt-Flag "Im Produktbestand anzeigen" (products.show_in_product_stock) Tests: 77 grün (DisposalReasonSettings 8, ProductOutOfStock 8, ProductStock 13 + Regression). Hinweise-Doku + Plan-Protokoll fortgeschrieben; nächster Schritt laut Plan: AP-21 (INCI-Erweiterungen). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
261 lines
15 KiB
PHP
261 lines
15 KiB
PHP
@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-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-outline-secondary">
|
||
<span class="fas fa-flask mr-1"></span>{{ __('Produzieren') }}</a>
|
||
</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">×</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');
|
||
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__']) }}";
|
||
$('.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
|