feat: Implement warehouse item stock audit functionality with search, type filtering, and dedicated audit pages.

This commit is contained in:
Yoga Pangestu 2026-02-12 09:08:31 +07:00
parent 1ebb50139a
commit 60fe24b7bf
12 changed files with 367 additions and 30 deletions

View File

@ -62,7 +62,7 @@ public function render(): View
{ {
return view('components.sections.ui.audit', [ return view('components.sections.ui.audit', [
'pageTitle' => 'Audit '.$this->label, 'pageTitle' => 'Audit '.$this->label,
'module' => 'bottle', 'backRoute' => route('studio.catalog.bottle.index'),
]); ]);
} }
} }

View File

@ -62,7 +62,7 @@ public function render(): View
{ {
return view('components.sections.ui.audit', [ return view('components.sections.ui.audit', [
'pageTitle' => 'Audit '.$this->label, 'pageTitle' => 'Audit '.$this->label,
'module' => 'perfume', 'backRoute' => route('studio.catalog.perfume.index'),
]); ]);
} }
} }

View File

@ -62,7 +62,7 @@ public function render(): View
{ {
return view('components.sections.ui.audit', [ return view('components.sections.ui.audit', [
'pageTitle' => 'Audit '.$this->label, 'pageTitle' => 'Audit '.$this->label,
'module' => 'product', 'backRoute' => route('studio.catalog.product.index'),
]); ]);
} }
} }

View File

@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Studio\Master\Warehouse\Audit;
use App\Enums\AuditStockStatus;
use App\Models\Bottle as BottleModel;
use App\Models\Warehouse;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Title;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
#[Title('Audit')]
class Bottle extends Component
{
public string $label;
public Collection $activityLogs;
public function mount(Warehouse $warehouse, BottleModel $bottle): void
{
$this->label = $bottle->name.' di '.$warehouse->name;
$this->activityLogs = Activity::where('subject_type', get_class($bottle))
->where('subject_id', $bottle->id)
->where('properties->warehouse_id', $warehouse->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('components.sections.ui.audit', [
'pageTitle' => 'Audit '.$this->label,
'backRoute' => route('studio.master.warehouse.show', ['warehouse' => request()->route('warehouse')]),
]);
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Studio\Master\Warehouse\Audit;
use App\Enums\AuditStockStatus;
use App\Models\Perfume as PerfumeModel;
use App\Models\Warehouse;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Title;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
#[Title('Audit')]
class Perfume extends Component
{
public string $label;
public Collection $activityLogs;
public function mount(Warehouse $warehouse, PerfumeModel $perfume): void
{
$this->label = $perfume->name.' di '.$warehouse->name;
$this->activityLogs = Activity::where('subject_type', get_class($perfume))
->where('subject_id', $perfume->id)
->where('properties->warehouse_id', $warehouse->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('components.sections.ui.audit', [
'pageTitle' => 'Audit '.$this->label,
'backRoute' => route('studio.master.warehouse.show', ['warehouse' => request()->route('warehouse')]),
]);
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Studio\Master\Warehouse\Audit;
use App\Enums\AuditStockStatus;
use App\Models\Product as ProductModel;
use App\Models\Warehouse;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Title;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
#[Title('Audit')]
class Product extends Component
{
public string $label;
public Collection $activityLogs;
public function mount(Warehouse $warehouse, ProductModel $product): void
{
$this->label = $product->name.' di '.$warehouse->name;
$this->activityLogs = Activity::where('subject_type', get_class($product))
->where('subject_id', $product->id)
->where('properties->warehouse_id', $warehouse->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('components.sections.ui.audit', [
'pageTitle' => 'Audit '.$this->label,
'backRoute' => route('studio.master.warehouse.show', ['warehouse' => request()->route('warehouse')]),
]);
}
}

View File

@ -15,12 +15,16 @@ class Show extends Component
public Warehouse $warehouse; public Warehouse $warehouse;
public string $search = '';
public array $type = [];
public function mount(Warehouse $warehouse): void public function mount(Warehouse $warehouse): void
{ {
$this->warehouse = $warehouse; $this->warehouse = $warehouse;
} }
public function getStatsProperty() public function getStatsProperty(): array
{ {
$perfumesCount = $this->warehouse->perfumes()->count(); $perfumesCount = $this->warehouse->perfumes()->count();
$productsCount = $this->warehouse->products()->count(); $productsCount = $this->warehouse->products()->count();
@ -50,33 +54,69 @@ public function getStatsProperty()
public function getItemsProperty() public function getItemsProperty()
{ {
$warehouse = $this->warehouse; $warehouse = $this->warehouse;
$search = $this->search;
$typeFilter = $this->type;
$perfumes = $warehouse->perfumes()->get()->map(function ($item) { $perfumes = collect();
$products = collect();
$bottles = collect();
// Load perfumes if no type filter or 'perfume' is selected
if (empty($typeFilter) || in_array('perfume', $typeFilter)) {
$perfumes = $warehouse->perfumes()
->when(! empty($search), function ($query) use ($search) {
return $query->where('name', 'like', '%'.$search.'%');
})
->get()
->map(function ($item) {
return [ return [
'id' => 'perfume_'.$item->id, 'id' => 'perfume_'.$item->id,
'hash_id' => $item->hash,
'type' => 'Parfum', 'type' => 'Parfum',
'type_key' => 'perfume',
'name' => $item->name, 'name' => $item->name,
'stock' => $item->pivot->stock ?? 0, 'stock' => $item->pivot->stock ?? 0,
]; ];
}); });
}
$products = $warehouse->products()->get()->map(function ($item) { // Load products if no type filter or 'product' is selected
if (empty($typeFilter) || in_array('product', $typeFilter)) {
$products = $warehouse->products()
->when(! empty($search), function ($query) use ($search) {
return $query->where('name', 'like', '%'.$search.'%');
})
->get()
->map(function ($item) {
return [ return [
'id' => 'product_'.$item->id, 'id' => 'product_'.$item->id,
'hash_id' => $item->hash,
'type' => 'Produk', 'type' => 'Produk',
'type_key' => 'product',
'name' => $item->name, 'name' => $item->name,
'stock' => $item->pivot->stock ?? 0, 'stock' => $item->pivot->stock ?? 0,
]; ];
}); });
}
$bottles = $warehouse->bottles()->get()->map(function ($item) { // Load bottles if no type filter or 'bottle' is selected
if (empty($typeFilter) || in_array('bottle', $typeFilter)) {
$bottles = $warehouse->bottles()
->when(! empty($search), function ($query) use ($search) {
return $query->where('name', 'like', '%'.$search.'%');
})
->get()
->map(function ($item) {
return [ return [
'id' => 'bottle_'.$item->id, 'id' => 'bottle_'.$item->id,
'hash_id' => $item->hash,
'type' => 'Botol', 'type' => 'Botol',
'type_key' => 'bottle',
'name' => $item->name, 'name' => $item->name,
'stock' => $item->pivot->stock ?? 0, 'stock' => $item->pivot->stock ?? 0,
]; ];
}); });
}
return $perfumes->concat($products)->concat($bottles); return $perfumes->concat($products)->concat($bottles);
} }

View File

@ -194,6 +194,7 @@ public function run(): void
'show warehouse', 'show warehouse',
'update warehouse', 'update warehouse',
'delete warehouse', 'delete warehouse',
'audit stock warehouse',
]; ];
@ -314,6 +315,7 @@ public function run(): void
'show warehouse', 'show warehouse',
'update warehouse', 'update warehouse',
'delete warehouse', 'delete warehouse',
'audit stock warehouse',
'view stock history', 'view stock history',
'show stock history', 'show stock history',
@ -462,6 +464,7 @@ public function run(): void
'show warehouse', 'show warehouse',
'update warehouse', 'update warehouse',
'delete warehouse', 'delete warehouse',
'audit stock warehouse',
'view stock history', 'view stock history',
'show stock history', 'show stock history',

View File

@ -0,0 +1,57 @@
# Fitur Audit Gudang
## Deskripsi
Fitur audit gudang memungkinkan pengguna untuk melihat riwayat perubahan stok untuk setiap item (parfum, produk, botol) di gudang tertentu. Fitur ini mirip dengan fitur audit stock di outlet, tetapi disesuaikan untuk konteks gudang.
## File yang Dibuat
### 1. Livewire Components
- `app/Livewire/Studio/Master/Warehouse/Audit/Perfume.php` - Component untuk audit parfum di gudang
- `app/Livewire/Studio/Master/Warehouse/Audit/Product.php` - Component untuk audit produk di gudang
- `app/Livewire/Studio/Master/Warehouse/Audit/Bottle.php` - Component untuk audit botol di gudang
### 2. View Template
- `resources/views/components/sections/ui/warehouse-audit.blade.php` - Template view untuk menampilkan audit log
## File yang Dimodifikasi
### 1. Routes
- `routes/pages/studio.php`
- Menambahkan import untuk 3 audit components
- Menambahkan 3 route baru untuk audit:
- `studio.master.warehouse.perfume.audit`
- `studio.master.warehouse.product.audit`
- `studio.master.warehouse.bottle.audit`
### 2. Warehouse Show Component
- `app/Livewire/Studio/Master/Warehouse/Show.php`
- Mengupdate method `getItemsProperty()` untuk menambahkan `hash_id` dan `type_key` pada setiap item
### 3. Warehouse Show View
- `resources/views/livewire/studio/master/warehouse/show.blade.php`
- Menambahkan kolom "Aksi" pada tabel
- Menambahkan tombol "Audit" untuk setiap item yang mengarah ke halaman audit
## Cara Penggunaan
1. Buka halaman detail gudang (`/studio/master/warehouses/{warehouse}/show`)
2. Pada tabel daftar item, klik tombol "Audit" pada item yang ingin dilihat riwayatnya
3. Halaman audit akan menampilkan:
- Log aktivitas stok (increase, decrease, updated, deleted)
- Informasi lokasi dan tipe lokasi
- Stok awal, perubahan, dan stok akhir
- User yang melakukan perubahan
- Deskripsi aktivitas
- Timestamp
## Permissions
Fitur ini menggunakan permission yang sama dengan audit outlet:
- `can:audit perfume`
- `can:audit product`
- `can:audit bottle`
## Catatan Teknis
- Activity log menggunakan Spatie Activity Log package
- Filter berdasarkan `properties->warehouse_id` untuk memastikan hanya menampilkan log yang relevan dengan gudang tertentu
- Menggunakan `AuditStockStatus` enum untuk status perubahan stok
- View menggunakan Flux UI components

View File

@ -4,7 +4,7 @@
<flux:heading size="xl">{{ $pageTitle }}</flux:heading> <flux:heading size="xl">{{ $pageTitle }}</flux:heading>
</div> </div>
<div> <div>
<flux:button href="{{ route('studio.catalog.' . $module . '.index') }}" wire:navigate class="text-sm"> <flux:button href="{{ $backRoute }}" wire:navigate class="text-sm">
Kembali Kembali
</flux:button> </flux:button>
</div> </div>

View File

@ -22,6 +22,19 @@
</div> </div>
@endif @endif
<div class="mb-6 grid grid-cols-1 lg:grid-cols-2 gap-4">
<flux:input icon="magnifying-glass" placeholder="Cari item..." wire:model.live.debounce.500ms="search"
autocomplete="off" />
<flux:checkbox.group wire:model.live="type" variant="buttons">
<flux:checkbox value="perfume" label="Parfum" />
<flux:checkbox value="product" label="Produk" />
<flux:checkbox value="bottle" label="Botol" />
</flux:checkbox.group>
</div>
<flux:separator class="mb-6" />
<flux:card class="mt-8"> <flux:card class="mt-8">
<div class="mb-4"> <div class="mb-4">
<flux:heading size="lg">Daftar Item</flux:heading> <flux:heading size="lg">Daftar Item</flux:heading>
@ -33,8 +46,12 @@
<flux:table.column>Tipe</flux:table.column> <flux:table.column>Tipe</flux:table.column>
<flux:table.column>Item</flux:table.column> <flux:table.column>Item</flux:table.column>
<flux:table.column>Stok</flux:table.column> <flux:table.column>Stok</flux:table.column>
@can('audit stock warehouse')
<flux:table.column>Aksi</flux:table.column>
@endcan
</flux:table.columns> </flux:table.columns>
<flux:table.rows> <flux:table.rows>
@forelse ($this->items as $item) @forelse ($this->items as $item)
<flux:table.row :key="$item['id']"> <flux:table.row :key="$item['id']">
@ -46,10 +63,20 @@
</flux:table.cell> </flux:table.cell>
<flux:table.cell class="font-medium">{{ $item['name'] }}</flux:table.cell> <flux:table.cell class="font-medium">{{ $item['name'] }}</flux:table.cell>
<flux:table.cell>{{ number_format($item['stock'], 0, ',', '.') }}</flux:table.cell> <flux:table.cell>{{ number_format($item['stock'], 0, ',', '.') }}</flux:table.cell>
<flux:table.cell>
@can('audit stock warehouse')
<flux:tooltip content="Audit">
<flux:button size="sm" variant="primary" icon="clipboard-document-list"
href="{{ route('studio.master.warehouse.' . $item['type_key'] . '.audit', ['warehouse' => $warehouse->hash, $item['type_key'] => $item['hash_id']]) }}"
wire:navigate>
</flux:button>
</flux:tooltip>
@endcan
</flux:table.cell>
</flux:table.row> </flux:table.row>
@empty @empty
<flux:table.row> <flux:table.row>
<flux:table.cell colspan="3" class="text-center text-zinc-500">Tidak ada item di gudang <flux:table.cell colspan="4" class="text-center text-zinc-500">Tidak ada item di gudang
ini. ini.
</flux:table.cell> </flux:table.cell>
</flux:table.row> </flux:table.row>

View File

@ -43,6 +43,9 @@
use App\Livewire\Studio\Master\User\Create as UserCreate; use App\Livewire\Studio\Master\User\Create as UserCreate;
use App\Livewire\Studio\Master\User\Edit as UserEdit; use App\Livewire\Studio\Master\User\Edit as UserEdit;
use App\Livewire\Studio\Master\User\Index as UserIndex; use App\Livewire\Studio\Master\User\Index as UserIndex;
use App\Livewire\Studio\Master\Warehouse\Audit\Bottle as WarehouseBottleAudit;
use App\Livewire\Studio\Master\Warehouse\Audit\Perfume as WarehousePerfumeAudit;
use App\Livewire\Studio\Master\Warehouse\Audit\Product as WarehouseProductAudit;
use App\Livewire\Studio\Master\Warehouse\Index as WarehouseIndex; use App\Livewire\Studio\Master\Warehouse\Index as WarehouseIndex;
use App\Livewire\Studio\Master\Warehouse\Show as WarehouseShow; use App\Livewire\Studio\Master\Warehouse\Show as WarehouseShow;
use App\Livewire\Studio\Setting\Account as AccountComponent; use App\Livewire\Studio\Setting\Account as AccountComponent;
@ -81,6 +84,9 @@
Route::prefix('warehouses')->name('warehouse.')->group(function () { Route::prefix('warehouses')->name('warehouse.')->group(function () {
Route::get('/', WarehouseIndex::class)->name('index')->middleware('can:view warehouse'); Route::get('/', WarehouseIndex::class)->name('index')->middleware('can:view warehouse');
Route::get('/{warehouse}/show', WarehouseShow::class)->name('show')->middleware('can:view warehouse'); Route::get('/{warehouse}/show', WarehouseShow::class)->name('show')->middleware('can:view warehouse');
Route::get('/{warehouse}/perfume/{perfume}/audit', WarehousePerfumeAudit::class)->name('perfume.audit')->middleware('can:audit perfume');
Route::get('/{warehouse}/product/{product}/audit', WarehouseProductAudit::class)->name('product.audit')->middleware('can:audit product');
Route::get('/{warehouse}/bottle/{bottle}/audit', WarehouseBottleAudit::class)->name('bottle.audit')->middleware('can:audit bottle');
}); });
Route::prefix('users')->name('user.')->group(function () { Route::prefix('users')->name('user.')->group(function () {