feat: Implement stock opname generation, add search functionality to stock opname items, and refine quantity input handling.

This commit is contained in:
Yoga Pangestu 2026-02-23 13:47:52 +07:00
parent d4b7c9d1db
commit d2b764d3f4
7 changed files with 62 additions and 52 deletions

View File

@ -54,7 +54,8 @@ public function update(): void
continue; continue;
} }
$item->qty_physical = str_replace('.', '', (string) $qtyPhysical); $item->qty_physical = (int) str_replace('.', '', (string) ($qtyPhysical ?? 0));
$item->save(); $item->save();
} }
} }

View File

@ -7,7 +7,9 @@
use App\Services\StockActivityLogService; use App\Services\StockActivityLogService;
use App\Traits\Components\WithConfirmation; use App\Traits\Components\WithConfirmation;
use App\Traits\Components\WithToast; use App\Traits\Components\WithToast;
use App\Traits\Utilities\WithRequestNotification;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
@ -15,36 +17,15 @@
#[Title('Stock Opname')] #[Title('Stock Opname')]
class Index extends Component class Index extends Component
{ {
use WithConfirmation, WithToast; use WithConfirmation, WithRequestNotification, WithToast;
public $stockOpname; public function generate(): void
public function mount(): void
{ {
$this->loadItems(); Artisan::call('stock-opname:generate');
}
protected function loadItems(): void $periodMonth = now()->format('Y-m');
{
$this->stockOpname = StockOpname::with(['outlet', 'items'])
->where('period_month', now()->format('Y-m'))
->get()
->map(function ($stockOpname) {
$total = $stockOpname->items()->count();
$filled = $stockOpname->items() $this->toast('Stock opname untuk bulan '.formatDateLocalized($periodMonth, 'F Y').' berhasil digenerate.', 'Berhasil');
->whereNotNull('qty_physical')
->count();
$percent = $total > 0 ? round(($filled / $total) * 100) : 0;
$stockOpname->total_item = $total;
$stockOpname->progress_filled = $filled;
$stockOpname->progress_percent = $percent;
$stockOpname->period_month = formatDateLocalized($stockOpname->period_month, 'F Y');
return $stockOpname;
});
} }
public function requestApproval(StockOpname $stockOpname): void public function requestApproval(StockOpname $stockOpname): void
@ -119,8 +100,29 @@ public function approveApproval(StockOpname $stockOpname): void
public function render(): View public function render(): View
{ {
$stockOpname = StockOpname::with(['outlet', 'items'])
->where('period_month', now()->format('Y-m'))
->get()
->map(function ($stockOpname) {
$total = $stockOpname->items()->count();
$filled = $stockOpname->items()
->whereNotNull('qty_physical')
->count();
$percent = $total > 0 ? round(($filled / $total) * 100) : 0;
$stockOpname->total_item = $total;
$stockOpname->progress_filled = $filled;
$stockOpname->progress_percent = $percent;
$stockOpname->period_month = formatDateLocalized($stockOpname->period_month, 'F Y');
return $stockOpname;
});
return view('livewire.studio.stock.stock-opname.index', [ return view('livewire.studio.stock.stock-opname.index', [
'pageTitle' => 'Stock Opname', 'pageTitle' => 'Stock Opname',
'stockOpname' => $stockOpname,
]); ]);
} }
} }

View File

@ -10,7 +10,6 @@
use App\Traits\Components\WithConfirmation; use App\Traits\Components\WithConfirmation;
use App\Traits\Components\WithToast; use App\Traits\Components\WithToast;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
@ -21,7 +20,7 @@ class Manage extends Component
public StockOpnameForm $form; public StockOpnameForm $form;
public Collection $items; public string $search = '';
public function mount(StockOpname $stockOpname): void public function mount(StockOpname $stockOpname): void
{ {
@ -29,12 +28,12 @@ public function mount(StockOpname $stockOpname): void
abort(403); abort(403);
} }
$this->items = StockOpnameItem::with('itemable') $items = StockOpnameItem::with('itemable')
->where('stock_opname_id', $stockOpname->id) ->where('stock_opname_id', $stockOpname->id)
->get(); ->get();
$outletStock = []; $outletStock = [];
foreach ($this->items as $item) { foreach ($items as $item) {
$outletStock[$item->id] = $item->qty_physical; $outletStock[$item->id] = $item->qty_physical;
} }
@ -43,35 +42,32 @@ public function mount(StockOpname $stockOpname): void
$this->form->setStockOpname($stockOpname); $this->form->setStockOpname($stockOpname);
} }
public function updated(string $propertyName, $value): void
{
$this->canOrAbort('manage stock opname');
if (str_starts_with($propertyName, 'form.outlet_stock.')) {
$itemId = explode('.', $propertyName)[2];
$item = $this->items->firstWhere('id', $itemId);
if ($item) {
$normalizedValue = str_replace('.', '', (string) $value);
$item->qty_physical = (int) $normalizedValue;
$item->difference = (int) $normalizedValue - $item->qty_system;
}
}
}
public function save(): void public function save(): void
{ {
$this->canOrAbort('manage stock opname'); $this->canOrAbort('manage stock opname');
$this->form->update(); $this->form->update();
$this->toast('Data stock opname berhasil disimpan.', 'Berhasil'); $this->redirectRoute('studio.stock.stock_opname.index', [
'notification' => 'Stock opname berhasil disimpan.',
], navigate: true);
} }
public function render(): View public function render(): View
{ {
$items = StockOpnameItem::with('itemable')
->where('stock_opname_id', $this->form->stockOpname->id)
->when($this->search, function ($query) {
$query->whereHasMorph('itemable', '*', function ($query) {
$query->where('name', 'like', '%'.$this->search.'%')
->orWhere('display_name', 'like', '%'.$this->search.'%');
});
})
->get();
return view('livewire.studio.stock.stock-opname.manage', [ return view('livewire.studio.stock.stock-opname.manage', [
'pageTitle' => 'Kelola Stock Opname', 'pageTitle' => 'Kelola Stock Opname',
'items' => $items,
]); ]);
} }
} }

View File

@ -19,7 +19,7 @@ protected function casts(): array
return [ return [
'qty_system' => 'int', 'qty_system' => 'int',
'qty_physical' => 'int', 'qty_physical' => 'int',
'differences' => 'int', 'difference' => 'int',
]; ];
} }

View File

@ -159,6 +159,7 @@ public function run(): void
'show stock opname', 'show stock opname',
'approve stock opname', 'approve stock opname',
'manage stock opname', 'manage stock opname',
'generate stock opname',
'view stock history', 'view stock history',
'show stock history', 'show stock history',
@ -291,6 +292,7 @@ public function run(): void
'update social settings', 'update social settings',
'approve stock opname', 'approve stock opname',
'generate stock opname',
'create tier', 'create tier',
'update tier', 'update tier',
@ -434,6 +436,7 @@ public function run(): void
'view stock opname', 'view stock opname',
'show stock opname', 'show stock opname',
'manage stock opname', 'manage stock opname',
'generate stock opname',
'view testimonial', 'view testimonial',
'delete testimonial', 'delete testimonial',

View File

@ -3,10 +3,17 @@
<div> <div>
<flux:heading size="xl">{{ $pageTitle }}</flux:heading> <flux:heading size="xl">{{ $pageTitle }}</flux:heading>
</div> </div>
<div class="flex gap-2">
@can('generate stock opname')
<flux:button variant="primary" class="text-sm" wire:click="generate">
Generate
</flux:button>
@endcan
</div>
</div> </div>
<div class="mt-6"> <div class="mt-6">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 mb-4 text-sm items-start" wire:ignore> <div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 mb-4 text-sm items-start">
@foreach ($stockOpname as $item) @foreach ($stockOpname as $item)
<flux:card class="h-auto" wire:key="stock-opname-{{ $item->id }}"> <flux:card class="h-auto" wire:key="stock-opname-{{ $item->id }}">
<div class="flex justify-between items-center mb-3"> <div class="flex justify-between items-center mb-3">

View File

@ -29,10 +29,11 @@
<flux:table.rows> <flux:table.rows>
@foreach ($items as $item) @foreach ($items as $item)
<flux:table.row wire:key="{{ $item->id }}"> <flux:table.row wire:key="{{ $item->id }}">
<flux:table.cell>{{ $item->itemable?->display_name ?? $item->itemable?->name }}</flux:table.cell> <flux:table.cell>{{ $item->itemable?->display_name ?? $item->itemable?->name }}
</flux:table.cell>
<flux:table.cell>{{ $item->qty_system }}</flux:table.cell> <flux:table.cell>{{ $item->qty_system }}</flux:table.cell>
<flux:table.cell> <flux:table.cell>
<flux:input wire:model.live.debounce.500ms="form.outlet_stock.{{ $item->id }}" <flux:input wire:model="form.outlet_stock.{{ $item->id }}"
x-mask:dynamic="$money($input, ',')" autocomplete="off" /> x-mask:dynamic="$money($input, ',')" autocomplete="off" />
</flux:table.cell> </flux:table.cell>
<flux:table.cell variant="strong"> <flux:table.cell variant="strong">