From 60fe24b7bf69e912c98b20854905fbbaf95c8a71 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 12 Feb 2026 09:08:31 +0700 Subject: [PATCH] feat: Implement warehouse item stock audit functionality with search, type filtering, and dedicated audit pages. --- app/Livewire/Studio/Catalog/Bottle/Audit.php | 2 +- app/Livewire/Studio/Catalog/Perfume/Audit.php | 2 +- app/Livewire/Studio/Catalog/Product/Audit.php | 2 +- .../Studio/Master/Warehouse/Audit/Bottle.php | 68 ++++++++++++++ .../Studio/Master/Warehouse/Audit/Perfume.php | 68 ++++++++++++++ .../Studio/Master/Warehouse/Audit/Product.php | 68 ++++++++++++++ app/Livewire/Studio/Master/Warehouse/Show.php | 90 +++++++++++++------ database/seeders/RolePermissionSeeder.php | 3 + docs/WAREHOUSE_AUDIT_FEATURE.md | 57 ++++++++++++ .../components/sections/ui/audit.blade.php | 2 +- .../studio/master/warehouse/show.blade.php | 29 +++++- routes/pages/studio.php | 6 ++ 12 files changed, 367 insertions(+), 30 deletions(-) create mode 100644 app/Livewire/Studio/Master/Warehouse/Audit/Bottle.php create mode 100644 app/Livewire/Studio/Master/Warehouse/Audit/Perfume.php create mode 100644 app/Livewire/Studio/Master/Warehouse/Audit/Product.php create mode 100644 docs/WAREHOUSE_AUDIT_FEATURE.md diff --git a/app/Livewire/Studio/Catalog/Bottle/Audit.php b/app/Livewire/Studio/Catalog/Bottle/Audit.php index 9db237d..a4618d0 100644 --- a/app/Livewire/Studio/Catalog/Bottle/Audit.php +++ b/app/Livewire/Studio/Catalog/Bottle/Audit.php @@ -62,7 +62,7 @@ public function render(): View { return view('components.sections.ui.audit', [ 'pageTitle' => 'Audit '.$this->label, - 'module' => 'bottle', + 'backRoute' => route('studio.catalog.bottle.index'), ]); } } diff --git a/app/Livewire/Studio/Catalog/Perfume/Audit.php b/app/Livewire/Studio/Catalog/Perfume/Audit.php index 655a040..cf0106c 100644 --- a/app/Livewire/Studio/Catalog/Perfume/Audit.php +++ b/app/Livewire/Studio/Catalog/Perfume/Audit.php @@ -62,7 +62,7 @@ public function render(): View { return view('components.sections.ui.audit', [ 'pageTitle' => 'Audit '.$this->label, - 'module' => 'perfume', + 'backRoute' => route('studio.catalog.perfume.index'), ]); } } diff --git a/app/Livewire/Studio/Catalog/Product/Audit.php b/app/Livewire/Studio/Catalog/Product/Audit.php index ee6360c..b7905b3 100644 --- a/app/Livewire/Studio/Catalog/Product/Audit.php +++ b/app/Livewire/Studio/Catalog/Product/Audit.php @@ -62,7 +62,7 @@ public function render(): View { return view('components.sections.ui.audit', [ 'pageTitle' => 'Audit '.$this->label, - 'module' => 'product', + 'backRoute' => route('studio.catalog.product.index'), ]); } } diff --git a/app/Livewire/Studio/Master/Warehouse/Audit/Bottle.php b/app/Livewire/Studio/Master/Warehouse/Audit/Bottle.php new file mode 100644 index 0000000..186b017 --- /dev/null +++ b/app/Livewire/Studio/Master/Warehouse/Audit/Bottle.php @@ -0,0 +1,68 @@ +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')]), + ]); + } +} diff --git a/app/Livewire/Studio/Master/Warehouse/Audit/Perfume.php b/app/Livewire/Studio/Master/Warehouse/Audit/Perfume.php new file mode 100644 index 0000000..f4e08b1 --- /dev/null +++ b/app/Livewire/Studio/Master/Warehouse/Audit/Perfume.php @@ -0,0 +1,68 @@ +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')]), + ]); + } +} diff --git a/app/Livewire/Studio/Master/Warehouse/Audit/Product.php b/app/Livewire/Studio/Master/Warehouse/Audit/Product.php new file mode 100644 index 0000000..878feb2 --- /dev/null +++ b/app/Livewire/Studio/Master/Warehouse/Audit/Product.php @@ -0,0 +1,68 @@ +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')]), + ]); + } +} diff --git a/app/Livewire/Studio/Master/Warehouse/Show.php b/app/Livewire/Studio/Master/Warehouse/Show.php index 5d9125e..f4496b1 100644 --- a/app/Livewire/Studio/Master/Warehouse/Show.php +++ b/app/Livewire/Studio/Master/Warehouse/Show.php @@ -15,12 +15,16 @@ class Show extends Component public Warehouse $warehouse; + public string $search = ''; + + public array $type = []; + public function mount(Warehouse $warehouse): void { $this->warehouse = $warehouse; } - public function getStatsProperty() + public function getStatsProperty(): array { $perfumesCount = $this->warehouse->perfumes()->count(); $productsCount = $this->warehouse->products()->count(); @@ -50,33 +54,69 @@ public function getStatsProperty() public function getItemsProperty() { $warehouse = $this->warehouse; + $search = $this->search; + $typeFilter = $this->type; - $perfumes = $warehouse->perfumes()->get()->map(function ($item) { - return [ - 'id' => 'perfume_'.$item->id, - 'type' => 'Parfum', - 'name' => $item->name, - 'stock' => $item->pivot->stock ?? 0, - ]; - }); + $perfumes = collect(); + $products = collect(); + $bottles = collect(); - $products = $warehouse->products()->get()->map(function ($item) { - return [ - 'id' => 'product_'.$item->id, - 'type' => 'Produk', - 'name' => $item->name, - 'stock' => $item->pivot->stock ?? 0, - ]; - }); + // 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 [ + 'id' => 'perfume_'.$item->id, + 'hash_id' => $item->hash, + 'type' => 'Parfum', + 'type_key' => 'perfume', + 'name' => $item->name, + 'stock' => $item->pivot->stock ?? 0, + ]; + }); + } - $bottles = $warehouse->bottles()->get()->map(function ($item) { - return [ - 'id' => 'bottle_'.$item->id, - 'type' => 'Botol', - 'name' => $item->name, - 'stock' => $item->pivot->stock ?? 0, - ]; - }); + // 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 [ + 'id' => 'product_'.$item->id, + 'hash_id' => $item->hash, + 'type' => 'Produk', + 'type_key' => 'product', + 'name' => $item->name, + 'stock' => $item->pivot->stock ?? 0, + ]; + }); + } + + // 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 [ + 'id' => 'bottle_'.$item->id, + 'hash_id' => $item->hash, + 'type' => 'Botol', + 'type_key' => 'bottle', + 'name' => $item->name, + 'stock' => $item->pivot->stock ?? 0, + ]; + }); + } return $perfumes->concat($products)->concat($bottles); } diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index b57e43e..3010a99 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -194,6 +194,7 @@ public function run(): void 'show warehouse', 'update warehouse', 'delete warehouse', + 'audit stock warehouse', ]; @@ -314,6 +315,7 @@ public function run(): void 'show warehouse', 'update warehouse', 'delete warehouse', + 'audit stock warehouse', 'view stock history', 'show stock history', @@ -462,6 +464,7 @@ public function run(): void 'show warehouse', 'update warehouse', 'delete warehouse', + 'audit stock warehouse', 'view stock history', 'show stock history', diff --git a/docs/WAREHOUSE_AUDIT_FEATURE.md b/docs/WAREHOUSE_AUDIT_FEATURE.md new file mode 100644 index 0000000..0d0bd0d --- /dev/null +++ b/docs/WAREHOUSE_AUDIT_FEATURE.md @@ -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 diff --git a/resources/views/components/sections/ui/audit.blade.php b/resources/views/components/sections/ui/audit.blade.php index f53f682..25016cd 100644 --- a/resources/views/components/sections/ui/audit.blade.php +++ b/resources/views/components/sections/ui/audit.blade.php @@ -4,7 +4,7 @@ {{ $pageTitle }}
- + Kembali
diff --git a/resources/views/livewire/studio/master/warehouse/show.blade.php b/resources/views/livewire/studio/master/warehouse/show.blade.php index 7d5e058..f1e2ccc 100644 --- a/resources/views/livewire/studio/master/warehouse/show.blade.php +++ b/resources/views/livewire/studio/master/warehouse/show.blade.php @@ -22,6 +22,19 @@ @endif +
+ + + + + + + +
+ + +
Daftar Item @@ -33,8 +46,12 @@ Tipe Item Stok + @can('audit stock warehouse') + Aksi + @endcan + @forelse ($this->items as $item) @@ -46,10 +63,20 @@ {{ $item['name'] }} {{ number_format($item['stock'], 0, ',', '.') }} + + @can('audit stock warehouse') + + + + + @endcan + @empty - Tidak ada item di gudang + Tidak ada item di gudang ini. diff --git a/routes/pages/studio.php b/routes/pages/studio.php index fe7420b..594325f 100644 --- a/routes/pages/studio.php +++ b/routes/pages/studio.php @@ -43,6 +43,9 @@ use App\Livewire\Studio\Master\User\Create as UserCreate; use App\Livewire\Studio\Master\User\Edit as UserEdit; 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\Show as WarehouseShow; use App\Livewire\Studio\Setting\Account as AccountComponent; @@ -81,6 +84,9 @@ Route::prefix('warehouses')->name('warehouse.')->group(function () { 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}/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 () {