feat: Add stock adjustment management to product, bottle, and perfume forms.

This commit is contained in:
Yoga Pangestu 2026-03-02 19:53:57 +07:00
parent a4b83ed6cb
commit 5abf7459da
8 changed files with 161 additions and 3 deletions

View File

@ -6,6 +6,7 @@
use App\Models\Outlet; use App\Models\Outlet;
use App\Rules\UnsignedInteger; use App\Rules\UnsignedInteger;
use App\Services\StockActivityLogService; use App\Services\StockActivityLogService;
use App\Traits\Forms\WithFormStockAdjustment;
use App\Traits\Media\WithMediaHandler; use App\Traits\Media\WithMediaHandler;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -13,7 +14,7 @@
class BottleForm extends Form class BottleForm extends Form
{ {
use WithMediaHandler; use WithFormStockAdjustment, WithMediaHandler;
public ?Bottle $bottle = null; public ?Bottle $bottle = null;
@ -70,6 +71,7 @@ public function setBottle(Bottle $bottle): void
$this->description = $bottle->description; $this->description = $bottle->description;
$this->outlet_ids = $this->bottle->outlets->pluck('id')->toArray(); $this->outlet_ids = $this->bottle->outlets->pluck('id')->toArray();
$this->image = $this->mapMediaCollection($bottle->getMedia('image')); $this->image = $this->mapMediaCollection($bottle->getMedia('image'));
$this->loadStockAdjustments($bottle);
} }
public function store(): void public function store(): void
@ -129,6 +131,8 @@ public function update(): void
} }
} }
$this->saveStockAdjustments($this->bottle, 'bottle');
$this->syncMedia($this->image, $this->bottle, 'image'); $this->syncMedia($this->image, $this->bottle, 'image');
$this->uploadMedia($this->image, $this->bottle, 'image'); $this->uploadMedia($this->image, $this->bottle, 'image');
}); });

View File

@ -7,6 +7,7 @@
use App\Models\Perfume; use App\Models\Perfume;
use App\Rules\UnsignedInteger; use App\Rules\UnsignedInteger;
use App\Services\StockActivityLogService; use App\Services\StockActivityLogService;
use App\Traits\Forms\WithFormStockAdjustment;
use App\Traits\Media\WithMediaHandler; use App\Traits\Media\WithMediaHandler;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -14,7 +15,7 @@
class PerfumeForm extends Form class PerfumeForm extends Form
{ {
use WithMediaHandler; use WithFormStockAdjustment, WithMediaHandler;
public ?Perfume $perfume = null; public ?Perfume $perfume = null;
@ -96,6 +97,7 @@ public function setPerfume(Perfume $perfume): void
$this->category_ids = $perfume->categories->pluck('id')->toArray(); $this->category_ids = $perfume->categories->pluck('id')->toArray();
$this->outlet_ids = $this->perfume->outlets->pluck('id')->toArray(); $this->outlet_ids = $this->perfume->outlets->pluck('id')->toArray();
$this->image = $this->mapMediaCollection($perfume->getMedia('image')); $this->image = $this->mapMediaCollection($perfume->getMedia('image'));
$this->loadStockAdjustments($perfume);
} }
public function store(): void public function store(): void
@ -163,6 +165,8 @@ public function update(): void
} }
} }
$this->saveStockAdjustments($this->perfume, 'perfume');
$this->syncMedia($data['image'], $this->perfume, 'image'); $this->syncMedia($data['image'], $this->perfume, 'image');
$this->uploadMedia($data['image'], $this->perfume, 'image'); $this->uploadMedia($data['image'], $this->perfume, 'image');
}); });

View File

@ -6,6 +6,7 @@
use App\Models\Product; use App\Models\Product;
use App\Rules\UnsignedInteger; use App\Rules\UnsignedInteger;
use App\Services\StockActivityLogService; use App\Services\StockActivityLogService;
use App\Traits\Forms\WithFormStockAdjustment;
use App\Traits\Media\WithMediaHandler; use App\Traits\Media\WithMediaHandler;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -13,7 +14,7 @@
class ProductForm extends Form class ProductForm extends Form
{ {
use WithMediaHandler; use WithFormStockAdjustment, WithMediaHandler;
public ?Product $product = null; public ?Product $product = null;
@ -67,6 +68,7 @@ public function setProduct(Product $product): void
$this->description = $product->description; $this->description = $product->description;
$this->outlet_ids = $this->product->outlets->pluck('id')->toArray(); $this->outlet_ids = $this->product->outlets->pluck('id')->toArray();
$this->image = $this->mapMediaCollection($product->getMedia('image')); $this->image = $this->mapMediaCollection($product->getMedia('image'));
$this->loadStockAdjustments($product);
} }
public function store(): void public function store(): void
@ -130,6 +132,8 @@ public function update(): void
} }
} }
$this->saveStockAdjustments($this->product, 'product');
$this->syncMedia($data['image'], $this->product, 'image'); $this->syncMedia($data['image'], $this->product, 'image');
$this->uploadMedia($data['image'], $this->product, 'image'); $this->uploadMedia($data['image'], $this->product, 'image');
}); });

View File

@ -0,0 +1,75 @@
<?php
namespace App\Traits\Forms;
use App\Models\Outlet;
use App\Services\StockActivityLogService;
use Illuminate\Database\Eloquent\Model;
trait WithFormStockAdjustment
{
public array $stock_adjustments = [];
protected function loadStockAdjustments(Model $item): void
{
$this->stock_adjustments = [
'outlet' => [],
];
$user = auth()->user();
$userOutletIds = $user->outlets->pluck('id')->toArray();
$itemOutlets = $item->outlets()
->whereIn('outlets.id', $userOutletIds)
->withPivot('stock')
->get();
foreach ($itemOutlets as $outlet) {
$stock = $outlet->pivot->stock ?? 0;
$this->stock_adjustments['outlet'][$outlet->id] = [
'name' => $outlet->name,
'stock' => $stock,
'new_stock' => (string) $stock,
'note' => '',
];
}
}
protected function saveStockAdjustments(Model $item, string $itemType): void
{
$itemName = $item->name.($itemType === 'bottle' ? " ({$item->size}ml)" : '');
if (isset($this->stock_adjustments['outlet'])) {
foreach ($this->stock_adjustments['outlet'] as $id => $adj) {
$newStock = (int) str_replace(['.', ','], '', (string) ($adj['new_stock'] ?: '0'));
$oldStock = (int) $adj['stock'];
if ($newStock === $oldStock) {
continue;
}
$outlet = Outlet::find($id);
if ($outlet) {
$item->outlets()->syncWithoutDetaching([$id => ['stock' => $newStock]]);
StockActivityLogService::logOutlet(
logName: 'adjustment',
event: 'update',
description: "Penyesuaian manual untuk {$itemName}: berubah dari ".formatCurrencyNumber($oldStock).' menjadi '.formatCurrencyNumber($newStock).'. Catatan: '.($adj['note'] ?: '-'),
performedOn: $item,
outlet: $outlet,
quantityChange: $newStock - $oldStock,
previousStock: $oldStock,
newStock: $newStock,
extraProperties: [
'note' => $adj['note'],
'adjustment_type' => 'manual',
'item_type' => $itemType,
'item_id' => $item->id,
]
);
}
}
}
}
}

View File

@ -0,0 +1,59 @@
@props(['form'])
<div class="mt-8 space-y-4">
<div class="flex items-center gap-2 px-1">
<flux:icon icon="map-pin" variant="outline" class="text-gray-400" size="sm" />
<flux:heading size="lg">Manajemen Stok Outlet</flux:heading>
</div>
@if (count($form->stock_adjustments['outlet'] ?? []) > 0)
<flux:card class="space-y-6">
<div class="flex items-center gap-2 border-b border-gray-100 dark:border-white/5 pb-4 -mx-6 px-6">
<div class="w-1.5 h-1.5 rounded-full bg-emerald-500"></div>
<flux:heading size="md" class="font-bold">Outlet Penjualan</flux:heading>
</div>
<div class="space-y-8">
@foreach ($form->stock_adjustments['outlet'] as $id => $adj)
<div class="space-y-4">
<div class="flex justify-between items-center">
<div class="flex flex-col">
<span
class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ $adj['name'] }}</span>
</div>
<flux:badge color="emerald" size="sm" variant="pill" class="font-mono">Stok:
{{ number_format($adj['stock']) }}</flux:badge>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<flux:field>
<flux:label class="text-xs font-medium opacity-70">Penyesuaian Stok Baru</flux:label>
<flux:input.group>
<flux:input.group.prefix class="bg-gray-50/50 dark:bg-white/5">
<flux:icon icon="plus" variant="mini" />
</flux:input.group.prefix>
<flux:input type="number"
wire:model="form.stock_adjustments.outlet.{{ $id }}.new_stock"
class="font-mono" />
</flux:input.group>
</flux:field>
<flux:field>
<flux:label class="text-xs font-medium opacity-70">Catatan Perubahan</flux:label>
<flux:input placeholder="Alasan penyesuaian..."
wire:model="form.stock_adjustments.outlet.{{ $id }}.note" autocomplete="off" />
</flux:field>
</div>
</div>
@if (!$loop->last)
<flux:separator variant="subtle" />
@endif
@endforeach
</div>
</flux:card>
@else
<flux:card class="p-6 text-center italic text-gray-400 text-sm">
Tidak ada outlet yang terhubung atau Anda tidak memiliki akses ke outlet manapun.
</flux:card>
@endif
</div>

View File

@ -63,6 +63,10 @@ class="col-span-{{ auth()->user()->hasRole(['Developer', 'Owner'])? '1': '2' }}"
</div> </div>
</div> </div>
</flux:card> </flux:card>
@if ($form->bottle?->exists)
<x-forms.stock-management :form="$form" />
@endif
</div> </div>
</div> </div>
</div> </div>

View File

@ -87,6 +87,10 @@ class="grid grid-cols-1 md:grid-cols-{{ auth()->user()->hasRole(['Developer', 'O
</div> </div>
</div> </div>
</flux:card> </flux:card>
@if ($form->perfume?->exists)
<x-forms.stock-management :form="$form" />
@endif
</div> </div>
</div> </div>
</div> </div>

View File

@ -58,6 +58,10 @@ class="col-span-{{ auth()->user()->hasRole(['Developer', 'Owner'])? '1': '2' }}"
</div> </div>
</div> </div>
</flux:card> </flux:card>
@if ($form->product?->exists)
<x-forms.stock-management :form="$form" />
@endif
</div> </div>
</div> </div>
</div> </div>