feat: Implement inventory history feature including dedicated pages, routes, navigation, and permissions.
This commit is contained in:
parent
6f0cf547e6
commit
c2bcb3d47b
69
app/Livewire/Studio/Stock/InventoryHistory/Index.php
Normal file
69
app/Livewire/Studio/Stock/InventoryHistory/Index.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Studio\Stock\InventoryHistory;
|
||||
|
||||
use App\Models\Bottle;
|
||||
use App\Models\Perfume;
|
||||
use App\Models\Product;
|
||||
use App\Traits\Authorization\WithAuthorization;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Riwayat Stok')]
|
||||
class Index extends Component
|
||||
{
|
||||
use WithAuthorization;
|
||||
|
||||
#[Url]
|
||||
public string $search = '';
|
||||
|
||||
#[Url]
|
||||
public string $typeFilter = 'all';
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
$perfumes = Perfume::with(['outlets', 'warehouses'])
|
||||
->when($this->search, fn ($q) => $q->where('name', 'like', '%'.$this->search.'%'))
|
||||
->when($this->typeFilter !== 'all' && $this->typeFilter !== 'perfume', fn ($q) => $q->whereNull('id'))
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
$item->type_label = 'Parfum';
|
||||
$item->type = 'perfume';
|
||||
$item->total_stock = $item->outlets->sum('pivot.stock') + $item->warehouses->sum('pivot.stock');
|
||||
|
||||
return $item;
|
||||
});
|
||||
|
||||
$products = Product::with(['outlets', 'warehouses'])
|
||||
->when($this->search, fn ($q) => $q->where('name', 'like', '%'.$this->search.'%'))
|
||||
->when($this->typeFilter !== 'all' && $this->typeFilter !== 'product', fn ($q) => $q->whereNull('id'))
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
$item->type_label = 'Produk';
|
||||
$item->type = 'product';
|
||||
$item->total_stock = $item->outlets->sum('pivot.stock') + $item->warehouses->sum('pivot.stock');
|
||||
|
||||
return $item;
|
||||
});
|
||||
|
||||
$bottles = Bottle::with(['outlets', 'warehouses'])
|
||||
->when($this->search, fn ($q) => $q->where('name', 'like', '%'.$this->search.'%'))
|
||||
->when($this->typeFilter !== 'all' && $this->typeFilter !== 'bottle', fn ($q) => $q->whereNull('id'))
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
$item->type_label = 'Botol';
|
||||
$item->type = 'bottle';
|
||||
$item->total_stock = $item->outlets->sum('pivot.stock') + $item->warehouses->sum('pivot.stock');
|
||||
|
||||
return $item;
|
||||
});
|
||||
|
||||
$items = $perfumes->concat($products)->concat($bottles)->sortBy('name');
|
||||
|
||||
return view('livewire.studio.stock.inventory-history.index', [
|
||||
'items' => $items,
|
||||
]);
|
||||
}
|
||||
}
|
||||
76
app/Livewire/Studio/Stock/InventoryHistory/Show.php
Normal file
76
app/Livewire/Studio/Stock/InventoryHistory/Show.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Studio\Stock\InventoryHistory;
|
||||
|
||||
use App\Enums\AuditStockStatus;
|
||||
use App\Models\Bottle;
|
||||
use App\Models\Perfume;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
#[Title('Riwayat Stok')]
|
||||
class Show extends Component
|
||||
{
|
||||
public $item;
|
||||
|
||||
public string $type;
|
||||
|
||||
public $activityLogs = [];
|
||||
|
||||
public function mount(string $type, string $hash): void
|
||||
{
|
||||
$this->type = $type;
|
||||
$this->item = match ($type) {
|
||||
'perfume' => Perfume::byHash($hash),
|
||||
'product' => Product::byHash($hash),
|
||||
'bottle' => Bottle::byHash($hash),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (! $this->item) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->activityLogs = Activity::where('subject_type', get_class($this->item))
|
||||
->where('subject_id', $this->item->id)
|
||||
->latest()
|
||||
->get()
|
||||
->map(function (Activity $activity) {
|
||||
$status = match ($activity->event) {
|
||||
'increase' => AuditStockStatus::INCREASED,
|
||||
'decrease' => AuditStockStatus::DECREASED,
|
||||
'created' => AuditStockStatus::INCREASED,
|
||||
'updated' => AuditStockStatus::UPDATED,
|
||||
'deleted' => AuditStockStatus::DELETED,
|
||||
default => AuditStockStatus::tryFrom($activity->event),
|
||||
};
|
||||
|
||||
$color = $status?->color() ?? 'gray';
|
||||
$label = $status?->label() ?? ucfirst($activity->event);
|
||||
|
||||
$classes = match ($color) {
|
||||
'emerald' => 'bg-emerald-200 text-emerald-800 dark:bg-emerald-800 dark:text-emerald-200',
|
||||
'orange' => 'bg-orange-200 text-orange-800 dark:bg-orange-800 dark:text-orange-200',
|
||||
'yellow' => 'bg-yellow-200 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-200',
|
||||
'red' => 'bg-red-200 text-red-800 dark:bg-red-800 dark:text-red-200',
|
||||
default => 'bg-gray-200 text-gray-800 dark:bg-gray-800 dark:text-gray-200',
|
||||
};
|
||||
|
||||
$activity->event = [
|
||||
'label' => $label,
|
||||
'color' => $color,
|
||||
'classes' => $classes,
|
||||
];
|
||||
|
||||
return $activity;
|
||||
});
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.studio.stock.inventory-history.show');
|
||||
}
|
||||
}
|
||||
@ -184,7 +184,7 @@ public function boot(): void
|
||||
],
|
||||
],
|
||||
[
|
||||
'heading' => 'Kelola',
|
||||
'heading' => 'Stok',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'Belanja',
|
||||
@ -214,6 +214,13 @@ public function boot(): void
|
||||
'match' => 'studio.stock.stock_opname.*',
|
||||
'can' => 'view stock opname',
|
||||
],
|
||||
[
|
||||
'label' => 'Riwayat Stok',
|
||||
'icon' => 'clock',
|
||||
'route' => 'studio.stock.inventory_history.index',
|
||||
'match' => 'studio.stock.inventory_history.*',
|
||||
'can' => 'view stock history',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@ -160,6 +160,9 @@ public function run(): void
|
||||
'approve stock opname',
|
||||
'manage stock opname',
|
||||
|
||||
'view stock history',
|
||||
'show stock history',
|
||||
|
||||
'view studio overview',
|
||||
|
||||
'view testimonial',
|
||||
@ -312,6 +315,9 @@ public function run(): void
|
||||
'update warehouse',
|
||||
'delete warehouse',
|
||||
|
||||
'view stock history',
|
||||
'show stock history',
|
||||
|
||||
]));
|
||||
|
||||
$admin->syncPermissions(array_diff($permissions, [
|
||||
@ -454,6 +460,8 @@ public function run(): void
|
||||
'update warehouse',
|
||||
'delete warehouse',
|
||||
|
||||
'view stock history',
|
||||
'show stock history',
|
||||
]));
|
||||
|
||||
$partner->syncPermissions([
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
<flux:main>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-center">
|
||||
<div>
|
||||
<flux:heading size="xl">Riwayat Stok</flux:heading>
|
||||
<flux:subheading>Lihat histori perubahan stok untuk semua barang.</flux:subheading>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Cari barang..." autocomplete="off"
|
||||
icon="magnifying-glass" class="flex-1" />
|
||||
|
||||
<div class="w-full sm:w-48">
|
||||
<flux:select variant="listbox" wire:model.live="typeFilter">
|
||||
<flux:select.option value="all">Semua Tipe</flux:select.option>
|
||||
<flux:select.option value="perfume">Parfum</flux:select.option>
|
||||
<flux:select.option value="product">Produk</flux:select.option>
|
||||
<flux:select.option value="bottle">Botol</flux:select.option>
|
||||
</flux:select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<flux:card class="overflow-hidden">
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>Nama Barang</flux:table.column>
|
||||
<flux:table.column>Tipe</flux:table.column>
|
||||
<flux:table.column>Stok Saat Ini</flux:table.column>
|
||||
<flux:table.column align="end">Aksi</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@forelse ($items as $item)
|
||||
<flux:table.row :key="$item->hash">
|
||||
<flux:table.cell class="font-medium">{{ $item->name }}</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:badge size="sm">{{ $item->type_label }}</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="font-semibold text-zinc-700 dark:text-zinc-200">
|
||||
{{ formatCurrencyNumber($item->total_stock) }}
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="end">
|
||||
<flux:button size="sm" variant="ghost" icon="clock"
|
||||
href="{{ route('studio.stock.inventory_history.show', ['type' => $item->type, 'hash' => $item->hash]) }}"
|
||||
wire:navigate>
|
||||
Riwayat
|
||||
</flux:button>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="4" class="text-center py-10">
|
||||
<div class="flex flex-col items-center justify-center text-zinc-500">
|
||||
<flux:icon name="archive-box" size="xl" class="mb-2 opacity-20" />
|
||||
<span>Tidak ada barang ditemukan.</span>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
</flux:card>
|
||||
</div>
|
||||
</flux:main>
|
||||
@ -0,0 +1,74 @@
|
||||
<flux:main>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<flux:heading size="xl">Riwayat Stok: {{ $item->name }}</flux:heading>
|
||||
<flux:subheading>Riwayat perubahan stok di berbagai lokasi.</flux:subheading>
|
||||
</div>
|
||||
<div>
|
||||
<flux:button href="{{ route('studio.stock.inventory_history.index') }}" wire:navigate class="text-sm">
|
||||
Kembali
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
@forelse ($activityLogs as $log)
|
||||
<flux:card class="p-4 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition text-gray-500 dark:text-gray-300">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<div class="text-md font-semibold text-zinc-800 dark:text-zinc-100">{{ $log->log_name }}</div>
|
||||
<div class="text-xs">{{ formatDateLocalized($log->created_at) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<div class="text-sm">
|
||||
<span
|
||||
class="font-medium">{{ $log->properties['location_name'] ?? 'Lokasi Tidak Diketahui' }}</span>
|
||||
<span class="text-xs opacity-70">({{ ucfirst($log->properties['location_type'] ?? '') }})</span>
|
||||
</div>
|
||||
<span
|
||||
class="px-2 py-0.5 inline-flex text-xs font-semibold rounded-full {{ $log->event['classes'] }}">
|
||||
{{ $log->event['label'] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<flux:separator class="my-2 dark:border-gray-700" />
|
||||
|
||||
<div class="flex flex-wrap gap-4 text-sm ">
|
||||
<div>Awal: <span
|
||||
class="font-medium text-zinc-700 dark:text-zinc-200">{{ formatCurrencyNumber($log->properties['previous_stock'] ?? 0) }}</span>
|
||||
</div>
|
||||
<flux:separator vertical class="hidden sm:block dark:border-gray-700" />
|
||||
<div>Perubahan: <span
|
||||
class="font-medium {{ ($log->properties['quantity_change'] ?? 0) >= 0 ? 'text-emerald-600' : 'text-orange-600' }}">{{ ($log->properties['quantity_change'] ?? 0) >= 0 ? '+' : '' }}{{ formatCurrencyNumber($log->properties['quantity_change'] ?? 0) }}</span>
|
||||
</div>
|
||||
<flux:separator vertical class="hidden sm:block dark:border-gray-700" />
|
||||
<div>Akhir: <span
|
||||
class="font-medium text-zinc-700 dark:text-zinc-200">{{ formatCurrencyNumber($log->properties['new_stock'] ?? 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<flux:separator class="my-2 dark:border-gray-700" />
|
||||
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<flux:icon name="user" size="sm" class="opacity-50" />
|
||||
<p class="text-xs">
|
||||
{{ $log->causer?->employee?->full_name ?? ($log->causer?->name ?? 'System') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400 italic">
|
||||
"{{ $log->description }}"
|
||||
</p>
|
||||
</flux:card>
|
||||
@empty
|
||||
@include('components.animations.lottie.boxing-cat')
|
||||
<div class="py-10 text-center text-zinc-500 italic">
|
||||
Belum ada riwayat tercatat untuk barang ini.
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:main>
|
||||
|
||||
@assets
|
||||
<script src="https://unpkg.com/@lottiefiles/dotlottie-wc@0.8.5/dist/dotlottie-wc.js" type="module"></script>
|
||||
@endassets
|
||||
@ -47,6 +47,8 @@
|
||||
use App\Livewire\Studio\Master\Warehouse\Show as WarehouseShow;
|
||||
use App\Livewire\Studio\Setting\Account as AccountComponent;
|
||||
use App\Livewire\Studio\Setting\Application as ApplicationComponent;
|
||||
use App\Livewire\Studio\Stock\InventoryHistory\Index as InventoryHistoryIndex;
|
||||
use App\Livewire\Studio\Stock\InventoryHistory\Show as InventoryHistoryShow;
|
||||
use App\Livewire\Studio\Stock\Purchase\Create as PurchaseCreate;
|
||||
use App\Livewire\Studio\Stock\Purchase\Index as PurchaseIndex;
|
||||
use App\Livewire\Studio\Stock\Restock\Create as RestockCreate;
|
||||
@ -192,6 +194,11 @@
|
||||
Route::get('/{stockOpname}/show', StockOpnameShow::class)->name('show')->middleware('can:show stock opname');
|
||||
Route::get('/{stockOpname}/manage', StockOpnameManage::class)->name('manage')->middleware('can:manage stock opname');
|
||||
});
|
||||
|
||||
Route::prefix('inventory-history')->name('inventory_history.')->group(function () {
|
||||
Route::get('/', InventoryHistoryIndex::class)->name('index')->middleware('can:view stock history');
|
||||
Route::get('/{type}/{hash}/show', InventoryHistoryShow::class)->name('show')->middleware('can:show stock history');
|
||||
});
|
||||
});
|
||||
|
||||
Route::prefix('information')->name('studio.information.')->group(function () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user