feat: Implement inventory history feature including dedicated pages, routes, navigation, and permissions.

This commit is contained in:
Yoga Pangestu 2026-02-10 11:45:48 +07:00
parent 6f0cf547e6
commit c2bcb3d47b
7 changed files with 307 additions and 1 deletions

View 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,
]);
}
}

View 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');
}
}

View File

@ -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',
],
],
],
[

View File

@ -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([

View File

@ -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>

View File

@ -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

View File

@ -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 () {