feat(audit): membuat enum untuk status

- menjadikan data outlet bisa di klik dan di redirect ke halaman audit
- catat log saat order create
- menambahkan permission
- menambahkan halaman baru
- kasih card penjelasan cara audit
This commit is contained in:
Yoga Pangestu 2025-11-01 15:54:03 +07:00
parent f55d4e70bd
commit c435d58f89
18 changed files with 380 additions and 26 deletions

View File

@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum AuditStockStatus: string
{
case INCREASED = 'Menambah';
case DECREASED = 'Mengurangi';
case UPDATED = 'Mengubah';
public function color()
{
return match ($this) {
self::INCREASED => 'emerald',
self::DECREASED => 'red',
self::UPDATED => 'yellow',
};
}
}

View File

@ -82,15 +82,25 @@ public function columns(): array
ArrayColumn::make('Outlet')
->data(
fn ($value, $row) => $row->outlets->map(fn ($outlet) => [
'id' => $outlet->hash,
'bottle_id' => $row->hash,
'name' => $outlet->name,
'stock' => currency($outlet->pivot->stock, ''),
])->toArray()
)
->outputFormat(fn ($index, $value) => Blade::render('
<div class="flex items-center space-x-2 bg-gray-50 border border-gray-200 rounded-lg px-2 py-1 text-xs">
<span class="font-medium text-gray-700">{{ $value["name"] }}</span>
<span class="text-[10px] text-white bg-emerald-500 rounded px-1.5 py-0.5">{{ $value["stock"] }}</span>
</div>', ['value' => $value]))
->outputFormat(function ($index, $value) {
$route = route('studio.catalog.bottle.audit', [$value['id'], $value['bottle_id']]);
return Blade::render('
<a href="{{ $route }}" wire:navigate
class="flex items-center space-x-2 bg-gray-50 border border-gray-200 rounded-lg px-2 py-1 text-xs hover:bg-gray-100 transition">
<span class="font-medium text-gray-700">{{ $value["name"] }}</span>
<span class="text-[10px] text-white bg-emerald-500 rounded px-1.5 py-0.5">{{ $value["stock"] }}</span>
</a>', [
'value' => $value,
'route' => $route,
]);
})
->flexRow(['class' => 'gap-2 flex-wrap']),
Column::make('Aksi')

View File

@ -85,15 +85,25 @@ public function columns(): array
ArrayColumn::make('Outlet')
->data(
fn ($value, $row) => $row->outlets->map(fn ($outlet) => [
'id' => $outlet->hash,
'perfume_id' => $row->hash,
'name' => $outlet->name,
'stock' => currency($outlet->pivot->stock, ''),
])->toArray()
)
->outputFormat(fn ($index, $value) => Blade::render('
<div class="flex items-center space-x-2 bg-gray-50 border border-gray-200 rounded-lg px-2 py-1 text-xs">
<span class="font-medium text-gray-700">{{ $value["name"] }}</span>
<span class="text-[10px] text-white bg-emerald-500 rounded px-1.5 py-0.5">{{ $value["stock"] }}</span>
</div>', ['value' => $value]))
->outputFormat(function ($index, $value) {
$route = route('studio.catalog.perfume.audit', [$value['id'], $value['perfume_id']]);
return Blade::render('
<a href="{{ $route }}" wire:navigate
class="flex items-center space-x-2 bg-gray-50 border border-gray-200 rounded-lg px-2 py-1 text-xs hover:bg-gray-100 transition">
<span class="font-medium text-gray-700">{{ $value["name"] }}</span>
<span class="text-[10px] text-white bg-emerald-500 rounded px-1.5 py-0.5">{{ $value["stock"] }}</span>
</a>', [
'value' => $value,
'route' => $route,
]);
})
->flexRow(['class' => 'gap-2 flex-wrap']),
Column::make('Aksi')

View File

@ -74,15 +74,25 @@ public function columns(): array
ArrayColumn::make('Outlet')
->data(
fn ($value, $row) => $row->outlets->map(fn ($outlet) => [
'id' => $outlet->hash,
'product_id' => $row->hash,
'name' => $outlet->name,
'stock' => currency($outlet->pivot->stock, ''),
])->toArray()
)
->outputFormat(fn ($index, $value) => Blade::render('
<div class="flex items-center space-x-2 bg-gray-50 border border-gray-200 rounded-lg px-2 py-1 text-xs">
<span class="font-medium text-gray-700">{{ $value["name"] }}</span>
<span class="text-[10px] text-white bg-emerald-500 rounded px-1.5 py-0.5">{{ $value["stock"] }}</span>
</div>', ['value' => $value]))
->outputFormat(function ($index, $value) {
$route = route('studio.catalog.product.audit', [$value['id'], $value['product_id']]);
return Blade::render('
<a href="{{ $route }}" wire:navigate
class="flex items-center space-x-2 bg-gray-50 border border-gray-200 rounded-lg px-2 py-1 text-xs hover:bg-gray-100 transition">
<span class="font-medium text-gray-700">{{ $value["name"] }}</span>
<span class="text-[10px] text-white bg-emerald-500 rounded px-1.5 py-0.5">{{ $value["stock"] }}</span>
</a>', [
'value' => $value,
'route' => $route,
]);
})
->flexRow(['class' => 'gap-2 flex-wrap']),
Column::make('Aksi')

View File

@ -0,0 +1,54 @@
<?php
namespace App\Livewire\Studio\Catalog\Bottle;
use App\Enums\AuditStockStatus;
use App\Models\Bottle;
use App\Models\Outlet;
use Livewire\Attributes\Title;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
#[Title('Audit')]
class Audit extends Component
{
public string $label;
public array $activityLogs = [];
public function mount(Outlet $outlet, Bottle $bottle)
{
$this->label = $bottle->name;
$this->activityLogs = Activity::where('subject_type', get_class($bottle))
->where('subject_id', $bottle->id)
->where('properties->outlet_id', $outlet->id)
->latest()
->get()
->map(function (Activity $activity) use ($outlet) {
return [
'created_at' => $activity->created_at,
'outlet' => $outlet->name,
'event' => [
'label' => $activity->event,
'color' => AuditStockStatus::from($activity->event)->color(),
],
'new_stock' => currency($activity->properties['new_stock']),
'previous_stock' => currency($activity->properties['previous_stock']),
'quantity_change' => currency($activity->properties['quantity_change']),
'user' => $activity->causer?->profile?->full_name,
'description' => $activity->description,
];
})
->toArray();
}
public function render()
{
return view('components.partials.audit', [
'pageTitle' => 'Audit '.$this->label,
'module' => 'bottle',
]);
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Livewire\Studio\Catalog\Perfume;
use App\Enums\AuditStockStatus;
use App\Models\Outlet;
use App\Models\Perfume;
use Livewire\Attributes\Title;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
#[Title('Audit')]
class Audit extends Component
{
public string $label;
public array $activityLogs = [];
public function mount(Outlet $outlet, Perfume $perfume)
{
$this->label = $perfume->name;
$this->activityLogs = Activity::where('subject_type', get_class($perfume))
->where('subject_id', $perfume->id)
->where('properties->outlet_id', $outlet->id)
->latest()
->get()
->map(function (Activity $activity) use ($outlet) {
return [
'created_at' => $activity->created_at,
'outlet' => $outlet->name,
'event' => [
'label' => $activity->event,
'color' => AuditStockStatus::from($activity->event)->color(),
],
'new_stock' => currency($activity->properties['new_stock']),
'previous_stock' => currency($activity->properties['previous_stock']),
'quantity_change' => currency($activity->properties['quantity_change']),
'user' => $activity->causer?->profile?->full_name,
'description' => $activity->description,
];
})
->toArray();
}
public function render()
{
return view('components.partials.audit', [
'pageTitle' => 'Audit '.$this->label,
'module' => 'perfume',
]);
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Livewire\Studio\Catalog\Product;
use App\Enums\AuditStockStatus;
use App\Models\Outlet;
use App\Models\Product;
use Livewire\Attributes\Title;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
#[Title('Audit')]
class Audit extends Component
{
public string $label;
public array $activityLogs = [];
public function mount(Outlet $outlet, Product $product)
{
$this->label = $product->name;
$this->activityLogs = Activity::where('subject_type', get_class($product))
->where('subject_id', $product->id)
->where('properties->outlet_id', $outlet->id)
->latest()
->get()
->map(function (Activity $activity) use ($outlet) {
return [
'created_at' => $activity->created_at,
'outlet' => $outlet->name,
'event' => [
'label' => $activity->event,
'color' => AuditStockStatus::from($activity->event)->color(),
],
'new_stock' => currency($activity->properties['new_stock']),
'previous_stock' => currency($activity->properties['previous_stock']),
'quantity_change' => currency($activity->properties['quantity_change']),
'user' => $activity->causer?->profile?->full_name,
'description' => $activity->description,
];
})
->toArray();
}
public function render()
{
return view('components.partials.audit', [
'pageTitle' => 'Audit '.$this->label,
'module' => 'product',
]);
}
}

View File

@ -54,7 +54,7 @@ public function mount()
$this->perfumes = Perfume::orderBy('name')->pluck('name', 'id')->toArray();
$this->bottles = Bottle::orderBy('name')->pluck('name', 'id')->toArray();
$this->bottles = Bottle::orderBy('size')->pluck('name', 'id')->toArray();
$this->products = Product::orderBy('name')->pluck('name', 'id')->toArray();

View File

@ -4,7 +4,6 @@
use App\Livewire\Forms\Studio\Manage\PurchaseForm;
use App\Models\Bottle;
use App\Models\Outlet;
use App\Models\Perfume;
use App\Models\Product;
use App\Traits\Purchase\WithAddItem;
@ -38,13 +37,13 @@ class Create extends Component
public function mount()
{
$this->outlets = Outlet::pluck('name', 'id')->toArray();
$this->outlets = auth()->user()->outlets->pluck('name', 'id')->toArray();
$this->perfumes = Perfume::pluck('name', 'id')->toArray();
$this->perfumes = Perfume::orderBy('name')->pluck('name', 'id')->toArray();
$this->products = Product::pluck('name', 'id')->toArray();
$this->bottles = Bottle::orderBy('size')->pluck('name', 'id')->toArray();
$this->bottles = Bottle::pluck('name', 'id')->toArray();
$this->products = Product::orderBy('name')->pluck('name', 'id')->toArray();
$this->purchaseItems = $this->loadPurchaseItems();

View File

@ -26,18 +26,34 @@ public function increaseOutletStock($outlet, $item)
return;
}
$existing = $outlet->{$relation}()->where("{$relation}.id", $item->purchasable_id)->first();
$existing = $outlet->{$relation}()->withPivot('stock')->where("{$relation}.id", $item->purchasable_id)->first();
if ($existing) {
$currentStock = $existing->pivot->stock ?? 0;
$previousStock = $existing->pivot->stock ?? 0;
$outlet->{$relation}()->updateExistingPivot($item->purchasable_id, [
'stock' => $currentStock + $quantity,
'stock' => $previousStock + $quantity,
]);
} else {
$previousStock = 0;
$outlet->{$relation}()->attach($item->purchasable_id, [
'stock' => $quantity,
]);
}
activity('Belanja')
->performedOn($item->purchasable)
->causedBy(auth()->user())
->withProperties([
'purchasable_id' => $item->purchasable_id,
'purchasable_type' => $item->purchasable_type,
'outlet_id' => $outlet->id,
'quantity_change' => $quantity,
'previous_stock' => $previousStock,
'new_stock' => $previousStock + $quantity,
'purchase_id' => $item->purchase_id ?? null,
])
->event('Menambah')
->log('Stok '.$item->purchasable->name.' di '.$outlet->name.' bertambah '.currency($quantity));
}
public function decreaseOutletStock($outlet, $item)
@ -58,7 +74,7 @@ public function decreaseOutletStock($outlet, $item)
return;
}
$existing = $outlet->{$relation}()->where("{$relation}.id", $item->purchasable_id)->first();
$existing = $outlet->{$relation}()->withPivot('stock')->where("{$relation}.id", $item->purchasable_id)->first();
if ($existing) {
$currentStock = $existing->pivot->stock ?? 0;
@ -67,5 +83,20 @@ public function decreaseOutletStock($outlet, $item)
'stock' => $newStock,
]);
}
activity('Belanja')
->performedOn($item->purchasable)
->causedBy(auth()->user())
->withProperties([
'purchasable_id' => $item->purchasable_id,
'purchasable_type' => $item->purchasable_type,
'outlet_id' => $outlet->id,
'quantity_change' => $quantity,
'previous_stock' => $currentStock,
'new_stock' => $newStock,
'purchase_id' => $item->purchase_id,
])
->event('Mengurangi')
->log('Stok '.$item->purchasable->name.' di '.$outlet->name.' berkurang '.$quantity);
}
}

View File

@ -73,16 +73,19 @@ public function run(): void
'create perfume',
'update perfume',
'delete perfume',
'audit perfume',
'view product',
'create product',
'update product',
'delete product',
'audit product',
'view bottle',
'create bottle',
'update bottle',
'delete bottle',
'audit bottle',
'view expense',
'create expense',
@ -153,14 +156,17 @@ public function run(): void
'create perfume',
'update perfume',
'delete perfume',
'audit perfume',
'create product',
'update product',
'delete product',
'audit product',
'create bottle',
'update bottle',
'delete bottle',
'audit bottle',
]));
$admin->syncPermissions(array_diff($permissions, [
@ -193,14 +199,17 @@ public function run(): void
'create perfume',
'update perfume',
'delete perfume',
'audit perfume',
'create product',
'update product',
'delete product',
'audit product',
'create bottle',
'update bottle',
'delete bottle',
'audit bottle',
'view article',
'create article',

View File

@ -0,0 +1,60 @@
<flux:main>
<div class="flex justify-between items-center">
<div>
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
</div>
<div>
<flux:button href="{{ route('studio.catalog.' . $module . '.index') }}" wire:navigate.hover class="text-sm">
Kembali
</flux:button>
</div>
</div>
<div class="mt-6 space-y-2">
@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-sm">{{ $log['created_at'] }}</div>
<div class=" text-sm">{{ $log['outlet'] }}</div>
<div class=" text-sm">{{ $log['user'] }}</div>
<span
class="px-2 py-1 inline-flex text-xs font-semibold rounded-full
bg-{{ $log['event']['color'] }}-200 text-{{ $log['event']['color'] }}-800
dark:bg-{{ $log['event']['color'] }}-800 ">
{{ $log['event']['label'] }}
</span>
</div>
<flux:separator class="my-2
dark:border-gray-700" />
<div class="flex flex-wrap gap-4 text-sm ">
<div>Stok Sebelumnya: {{ $log['previous_stock'] }}</div>
<flux:separator vertical class="dark:border-gray-700" />
<div>Perubahan: {{ $log['quantity_change'] }}</div>
<flux:separator vertical class="dark:border-gray-700" />
<div>Total Stok: {{ $log['new_stock'] }}</div>
</div>
<flux:separator class="my-2 dark:border-gray-700" />
<p class="mt-2text-sm truncate">
{{ $log['description'] }}
</p>
</flux:card>
@empty
<div class="flex justify-center flex-col">
<dotlottie-wc src="https://lottie.host/4282c234-6d0f-4a8d-ad04-3d82f2783814/FuhTCVgPGi.lottie" autoplay
loop style="width: 200px; height: 200px; margin: 0 auto;">
</dotlottie-wc>
<flux:text class="block text-center italic mt-5">
Yahhh, sepertinya belum ada data yang tersedia.
</flux:text>
</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

@ -14,6 +14,14 @@ class="text-sm">
</div>
<div class="mt-6">
<flux:callout icon="megaphone" color="blue" class="mb-6">
<flux:callout.heading>Informasi</flux:callout.heading>
<flux:callout.text>
Untuk melihat harga pembelian, silakan klik pada bagian harga. Untuk melakukan audit stok,
silakan klik pada nama outlet.
</flux:callout.text>
</flux:callout>
<livewire:datatable.studio.catalog.bottles-table />
</div>

View File

@ -14,6 +14,14 @@ class="text-sm">
</div>
<div class="mt-6">
<flux:callout icon="megaphone" color="blue" class="mb-6">
<flux:callout.heading>Informasi</flux:callout.heading>
<flux:callout.text>
Untuk melihat harga pembelian, silakan klik pada bagian harga. Untuk melakukan audit stok,
silakan klik pada nama outlet.
</flux:callout.text>
</flux:callout>
<livewire:datatable.studio.catalog.perfumes-table />
</div>

View File

@ -14,6 +14,14 @@ class="text-sm">
</div>
<div class="mt-6">
<flux:callout icon="megaphone" color="blue" class="mb-6">
<flux:callout.heading>Informasi</flux:callout.heading>
<flux:callout.text>
Untuk melihat harga pembelian, silakan klik pada bagian harga. Untuk melakukan audit stok,
silakan klik pada nama outlet.
</flux:callout.text>
</flux:callout>
<livewire:datatable.studio.catalog.products-table />
</div>

View File

@ -11,6 +11,13 @@
</div>
<div class="mt-6">
<flux:callout icon="megaphone" color="blue" class="mb-6">
<flux:callout.heading>Informasi</flux:callout.heading>
<flux:callout.text>
Tolong perhatikan data yang Anda masukkan, karena tidak dapat diubah setelah disimpan.
</flux:callout.text>
</flux:callout>
<div class="flex flex-col lg:flex-row gap-4">
<div class="w-full lg:w-3/4 space-y-4">
<flux:card class="space-y-6 p-6">

View File

@ -3,7 +3,7 @@
<div>
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
</div>
@can ('create purchase')
@can('create purchase')
<div>
<flux:button href="{{ route('studio.manage.purchase.create') }}" variant="primary" wire:navigate.hover
class="text-sm">
@ -14,6 +14,13 @@ class="text-sm">
</div>
<div class="mt-6">
<flux:callout icon="megaphone" color="blue" class="mb-6">
<flux:callout.heading>Informasi</flux:callout.heading>
<flux:callout.text>
Menghapus data belanja otomatis akan menambah stok.
</flux:callout.text>
</flux:callout>
<livewire:datatable.studio.manage.purchases-table />
</div>

View File

@ -1,14 +1,17 @@
<?php
use App\Http\Controllers\Studio\PriceRequestController;
use App\Livewire\Studio\Catalog\Bottle\Audit as BottleAudit;
use App\Livewire\Studio\Catalog\Bottle\Create as BottleCreate;
use App\Livewire\Studio\Catalog\Bottle\Edit as BottleEdit;
use App\Livewire\Studio\Catalog\Bottle\Index as BottleIndex;
use App\Livewire\Studio\Catalog\Brand as BrandComponent;
use App\Livewire\Studio\Catalog\Category as CategoryComponent;
use App\Livewire\Studio\Catalog\Perfume\Audit as PerfumeAudit;
use App\Livewire\Studio\Catalog\Perfume\Create as PerfumeCreate;
use App\Livewire\Studio\Catalog\Perfume\Edit as PerfumeEdit;
use App\Livewire\Studio\Catalog\Perfume\Index as PerfumeIndex;
use App\Livewire\Studio\Catalog\Product\Audit as ProductAudit;
use App\Livewire\Studio\Catalog\Product\Create as ProductCreate;
use App\Livewire\Studio\Catalog\Product\Edit as ProductEdit;
use App\Livewire\Studio\Catalog\Product\Index as ProductIndex;
@ -108,6 +111,7 @@
Route::get('/create', PerfumeCreate::class)->name('create')->middleware('can:create perfume');
Route::get('/{perfume}/edit', PerfumeEdit::class)->name('edit')->middleware('can:update perfume');
Route::delete('/{perfume}/delete', PerfumeCreate::class)->name('delete')->middleware('can:delete perfume');
Route::get('/{outlet}/{perfume}/audit', PerfumeAudit::class)->name('audit')->middleware('can:audit perfume');
});
Route::prefix('products')->name('product.')->group(function () {
@ -115,6 +119,7 @@
Route::get('/create', ProductCreate::class)->name('create')->middleware('can:create product');
Route::get('/{product}/edit', ProductEdit::class)->name('edit')->middleware('can:update product');
Route::delete('/{product}/delete', ProductCreate::class)->name('delete')->middleware('can:delete product');
Route::get('/{outlet}/{product}/audit', ProductAudit::class)->name('audit')->middleware('can:audit product');
});
Route::prefix('bottles')->name('bottle.')->group(function () {
@ -122,6 +127,7 @@
Route::get('/create', BottleCreate::class)->name('create')->middleware('can:create bottle');
Route::get('/{bottle}/edit', BottleEdit::class)->name('edit')->middleware('can:update bottle');
Route::delete('/{bottle}/delete', BottleCreate::class)->name('delete')->middleware('can:delete bottle');
Route::get('/{outlet}/{bottle}/audit', BottleAudit::class)->name('audit')->middleware('can:audit bottle');
});
});