diff --git a/app/Livewire/Datatable/Manage/RestocksTable.php b/app/Livewire/Datatable/Manage/RestocksTable.php
new file mode 100644
index 0000000..54e1533
--- /dev/null
+++ b/app/Livewire/Datatable/Manage/RestocksTable.php
@@ -0,0 +1,77 @@
+searchable(),
+
+ Column::make('Outlet', 'outlet.name')->searchable(),
+
+ Column::make('Tanggal Restock', 'restock_date')
+ ->format(fn ($value) => formatDateLocalized($value))
+ ->searchable()
+ ->sortable(),
+
+ Column::make('Catatan', 'note')->searchable(),
+
+ ArrayColumn::make('Item')
+ ->data(
+ fn ($value, $row) => $row->items->map(fn ($item) => [
+ 'name' => $item->restockable?->name,
+ 'quantity' => formatCurrencyNumber($item->quantity, ''),
+ ])->toArray()
+ )
+ ->outputFormat(
+ fn ($index, $value) => "
+
+
{$value['name']}
+
Qty: {$value['quantity']}
+
"
+ )
+ ->flexCol(['class' => 'flex-col gap-3']),
+
+ Column::make('Aksi')
+ ->label(function ($row) {
+ $actions = '';
+
+ if (auth()->user()->can('delete restock')) {
+ $actions .= view('components.actions.table.delete', [
+ 'id' => $row->hash,
+ ])->render();
+ }
+
+ return $actions;
+ })
+ ->html()
+ ->hideIf(auth()->user()->cannot('update restock')),
+ ];
+ }
+
+ public function builder(): Builder
+ {
+ return Restock::select('restocks.id', 'warehouse_id', 'outlet_id', 'restock_date', 'note')
+ ->with(['warehouse', 'outlet', 'items', 'items.restockable'])
+ ->whereIn('outlet_id', auth()->user()->outlets->pluck('id')->toArray());
+ }
+}
diff --git a/app/Livewire/Forms/Studio/Manage/RestockForm.php b/app/Livewire/Forms/Studio/Manage/RestockForm.php
new file mode 100644
index 0000000..3015cb7
--- /dev/null
+++ b/app/Livewire/Forms/Studio/Manage/RestockForm.php
@@ -0,0 +1,129 @@
+ ['nullable', 'string', 'max:100'],
+ 'restock_date' => ['required', 'date', 'before_or_equal:today'],
+ 'warehouse_id' => ['required', Rule::exists('warehouses', 'id')],
+ 'outlet_id' => ['required', Rule::exists('outlets', 'id')],
+ 'image' => ['nullable', 'array', 'max:1'],
+ ];
+ }
+
+ public function validationAttributes(): array
+ {
+ return [
+ 'note' => 'catatan',
+ 'restock_date' => 'tanggal restock',
+ 'warehouse_id' => 'gudang',
+ 'outlet_id' => 'outlet',
+ 'image' => 'gambar',
+ ];
+ }
+
+ public function setRestock(Restock $restock): void
+ {
+ $this->restock = $restock;
+
+ $this->note = $restock->note;
+ $this->restock_date = $restock->restock_date;
+ $this->warehouse_id = $restock->warehouse_id;
+ $this->outlet_id = $restock->outlet_id;
+
+ $this->image = $this->mapMediaCollection($restock->getMedia('image'));
+ }
+
+ public function store(): void
+ {
+ $this->validate();
+
+ $restockItems = $this->loadRestockItems();
+
+ DB::transaction(function () use ($restockItems) {
+ $data = $this->prepareSavedData();
+
+ $restock = Restock::create($data);
+
+ $restock->load(['warehouse', 'outlet']);
+
+ $this->uploadMedia($this->image, $restock, 'image');
+
+ foreach ($restockItems as $item) {
+ if ($item->quantity <= 0) {
+ continue;
+ }
+
+ $item->update(['restock_id' => $restock->id]);
+
+ $this->transferStockFromWarehouseToOutlet($restock->warehouse, $restock->outlet, $item);
+ }
+ });
+ }
+
+ public function update(): void
+ {
+ $this->validate();
+
+ DB::transaction(function () {
+ $data = $this->prepareSavedData();
+
+ $this->restock->update($data);
+
+ $this->syncMedia($this->image, $this->restock, 'image');
+ $this->uploadMedia($this->image, $this->restock, 'image');
+ });
+ }
+
+ private function prepareSavedData(): array
+ {
+ return [
+ 'note' => $this->note,
+ 'warehouse_id' => $this->warehouse_id,
+ 'outlet_id' => $this->outlet_id,
+ 'restock_date' => $this->restock_date,
+ ];
+ }
+}
diff --git a/app/Livewire/Studio/Manage/Restock/Create.php b/app/Livewire/Studio/Manage/Restock/Create.php
new file mode 100644
index 0000000..626c113
--- /dev/null
+++ b/app/Livewire/Studio/Manage/Restock/Create.php
@@ -0,0 +1,127 @@
+warehouses = Warehouse::orderBy('name')->pluck('name', 'id')->toArray();
+ $this->outlets = auth()->user()->outlets->pluck('name', 'id')->toArray();
+
+ $this->restockItems = $this->loadRestockItems();
+ }
+
+ public function updated($property, $value): void
+ {
+ if ($property === 'form.warehouse_id' || $property === 'form.outlet_id') {
+ $this->loadCreateItems();
+ }
+ }
+
+ private function loadCreateItems(): void
+ {
+ $this->reset(['perfumes', 'products', 'bottles']);
+
+ $this->form->reset(
+ 'perfume_id',
+ 'quantity_perfume',
+ 'product_id',
+ 'quantity_product',
+ 'bottle_id',
+ 'quantity_bottle'
+ );
+
+ if (! $this->form->warehouse_id || ! $this->form->outlet_id) {
+ return;
+ }
+
+ $warehouse = Warehouse::find($this->form->warehouse_id);
+
+ if (! $warehouse) {
+ return;
+ }
+
+ $this->perfumes = $warehouse->perfumes()
+ ->wherePivot('stock', '>', 0)
+ ->orderBy('name')
+ ->get()
+ ->pluck('name', 'id')
+ ->toArray();
+
+ $this->products = $warehouse->products()
+ ->wherePivot('stock', '>', 0)
+ ->orderBy('name')
+ ->get()
+ ->pluck('name', 'id')
+ ->toArray();
+
+ $this->bottles = $warehouse->bottles()
+ ->wherePivot('stock', '>', 0)
+ ->orderBy('name')
+ ->get()
+ ->pluck('name', 'id')
+ ->toArray();
+ }
+
+ public function save(): void
+ {
+ $this->canOrAbort('create restock');
+
+ if ($this->restockItems->isEmpty()) {
+ $this->toast('Keranjang restock tidak boleh kosong.', 'Gagal', 'danger');
+
+ return;
+ }
+
+ try {
+ $this->form->store();
+ } catch (\Exception $e) {
+ $this->toast($e->getMessage(), 'Gagal', 'danger');
+
+ return;
+ }
+
+ $this->redirectRoute('studio.manage.restock.index', [
+ 'notification' => 'Restock berhasil diproses.',
+ ], navigate: true);
+ }
+
+ public function render(): View
+ {
+ return view('livewire.studio.manage.restock.form', [
+ 'pageTitle' => 'Tambah Restock Toko',
+ ]);
+ }
+}
diff --git a/app/Livewire/Studio/Manage/Restock/Index.php b/app/Livewire/Studio/Manage/Restock/Index.php
new file mode 100644
index 0000000..802e258
--- /dev/null
+++ b/app/Livewire/Studio/Manage/Restock/Index.php
@@ -0,0 +1,49 @@
+canOrAbort('delete restock');
+
+ DB::transaction(function () use ($restock) {
+ foreach ($restock->items as $item) {
+ $this->reverseStockTransfer($restock->warehouse, $restock->outlet, $item);
+ }
+
+ $restock->delete();
+ });
+
+ $this->dispatch('refreshDatatable');
+
+ $this->toast('Restock berhasil dihapus.');
+
+ Flux::modals()->close();
+ }
+
+ public function render(): View
+ {
+ return view('livewire.studio.manage.restock.index', [
+ 'pageTitle' => 'Restock Toko',
+ ]);
+ }
+}
diff --git a/app/Models/Restock.php b/app/Models/Restock.php
new file mode 100644
index 0000000..8ff7797
--- /dev/null
+++ b/app/Models/Restock.php
@@ -0,0 +1,55 @@
+ 'date',
+ ];
+ }
+
+ #[Scope]
+ public function today(Builder $query): void
+ {
+ $query->whereDate('created_at', today());
+ }
+
+ #[Scope]
+ public function yesterday(Builder $query): void
+ {
+ $query->whereDate('created_at', today()->subDay());
+ }
+
+ public function warehouse(): BelongsTo
+ {
+ return $this->belongsTo(Warehouse::class);
+ }
+
+ public function outlet(): BelongsTo
+ {
+ return $this->belongsTo(Outlet::class);
+ }
+
+ public function items(): HasMany
+ {
+ return $this->hasMany(RestockItem::class);
+ }
+}
diff --git a/app/Models/RestockItem.php b/app/Models/RestockItem.php
new file mode 100644
index 0000000..0eed277
--- /dev/null
+++ b/app/Models/RestockItem.php
@@ -0,0 +1,46 @@
+ 'int',
+ ];
+ }
+
+ #[Scope]
+ public function currentUser(Builder $query): void
+ {
+ $query->where('user_id', auth()->id());
+ }
+
+ public function restock(): BelongsTo
+ {
+ return $this->belongsTo(Restock::class);
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function restockable(): MorphTo
+ {
+ return $this->morphTo();
+ }
+}
diff --git a/app/Providers/ViewServiceProvider.php b/app/Providers/ViewServiceProvider.php
index 3a0c807..a7b2c5e 100644
--- a/app/Providers/ViewServiceProvider.php
+++ b/app/Providers/ViewServiceProvider.php
@@ -182,7 +182,7 @@ public function boot(): void
'can' => 'view purchase',
],
[
- 'label' => 'Restock Toko',
+ 'label' => 'Restock Outlet',
'icon' => 'arrows-right-left',
'route' => 'studio.manage.restock.index',
'match' => 'studio.manage.restock.*',
diff --git a/app/Traits/Restock/WithRestockAddItem.php b/app/Traits/Restock/WithRestockAddItem.php
new file mode 100644
index 0000000..e59ff69
--- /dev/null
+++ b/app/Traits/Restock/WithRestockAddItem.php
@@ -0,0 +1,112 @@
+canOrAbort('create restock');
+
+ if (! $this->form->warehouse_id) {
+ $this->toast('Silakan pilih gudang terlebih dahulu.', 'Gagal', 'warning');
+
+ return;
+ }
+
+ $warehouse = Warehouse::find($this->form->warehouse_id);
+
+ if (! $warehouse) {
+ $this->toast('Gudang tidak ditemukan.', 'Gagal', 'danger');
+
+ return;
+ }
+
+ $data = match ($type) {
+ 'parfum' => [
+ 'restockable_type' => Perfume::class,
+ 'restockable_id' => $this->form->perfume_id,
+ 'quantity' => parseRupiahToInt($this->form->quantity_perfume),
+ ],
+ 'produk' => [
+ 'restockable_type' => Product::class,
+ 'restockable_id' => $this->form->product_id,
+ 'quantity' => parseRupiahToInt($this->form->quantity_product),
+ ],
+ 'botol' => [
+ 'restockable_type' => Bottle::class,
+ 'restockable_id' => $this->form->bottle_id,
+ 'quantity' => parseRupiahToInt($this->form->quantity_bottle),
+ ],
+ default => null,
+ };
+
+ if (! $data || ! $data['restockable_id'] || $data['quantity'] <= 0) {
+ $this->toast('Data item tidak valid.', 'Gagal', 'danger');
+
+ return;
+ }
+
+ // Validate stock availability
+ $relationName = match ($type) {
+ 'parfum' => 'perfumes',
+ 'produk' => 'products',
+ 'botol' => 'bottles',
+ default => null,
+ };
+
+ if ($relationName) {
+ $warehouseStock = $warehouse->{$relationName}()
+ ->where($data['restockable_type']::make()->getTable().'.id', $data['restockable_id'])
+ ->first()
+ ?->pivot
+ ?->stock ?? 0;
+
+ // Is item already in cart?
+ $existing = RestockItem::where([
+ 'user_id' => auth()->id(),
+ 'restockable_id' => $data['restockable_id'],
+ 'restockable_type' => $data['restockable_type'],
+ ])->whereNull('restock_id')->first();
+
+ $currentCartQty = $existing ? $existing->quantity : 0;
+ $requestedQty = $data['quantity'];
+
+ if (($currentCartQty + $requestedQty) > $warehouseStock) {
+ $this->toast("Stok tidak mencukupi. Stok di gudang: {$warehouseStock}. Sudah di keranjang: {$currentCartQty}.", 'Gagal', 'danger');
+
+ return;
+ }
+
+ if ($existing) {
+ $existing->increment('quantity', $requestedQty);
+ } else {
+ RestockItem::create(array_merge($data, ['user_id' => auth()->id()]));
+ }
+ }
+
+ $this->resetFormItem($type);
+ $this->restockItems = $this->loadRestockItems();
+ $this->toast('Item berhasil ditambahkan ke keranjang.');
+ }
+
+ private function resetFormItem(string $type): void
+ {
+ if ($type === 'parfum') {
+ $this->form->perfume_id = '';
+ $this->form->quantity_perfume = '';
+ } elseif ($type === 'produk') {
+ $this->form->product_id = '';
+ $this->form->quantity_product = '';
+ } elseif ($type === 'botol') {
+ $this->form->bottle_id = '';
+ $this->form->quantity_bottle = '';
+ }
+ }
+}
diff --git a/app/Traits/Restock/WithRestockDeleteItem.php b/app/Traits/Restock/WithRestockDeleteItem.php
new file mode 100644
index 0000000..8f16c0b
--- /dev/null
+++ b/app/Traits/Restock/WithRestockDeleteItem.php
@@ -0,0 +1,18 @@
+canOrAbort('create restock');
+
+ $item->delete();
+
+ $this->restockItems = $this->loadRestockItems();
+ $this->toast('Item berhasil dihapus dari keranjang.');
+ }
+}
diff --git a/app/Traits/Restock/WithRestockItems.php b/app/Traits/Restock/WithRestockItems.php
new file mode 100644
index 0000000..01ba8b2
--- /dev/null
+++ b/app/Traits/Restock/WithRestockItems.php
@@ -0,0 +1,19 @@
+restockItems = RestockItem::query()
+ ->currentUser()
+ ->where('user_id', auth()->id())
+ ->whereNull('restock_id')
+ ->latest()
+ ->get();
+ }
+}
diff --git a/app/Traits/Restock/WithRestockStock.php b/app/Traits/Restock/WithRestockStock.php
new file mode 100644
index 0000000..115cee3
--- /dev/null
+++ b/app/Traits/Restock/WithRestockStock.php
@@ -0,0 +1,169 @@
+quantity;
+
+ switch ($item->restockable_type) {
+ case Perfume::class:
+ $relation = 'perfumes';
+ break;
+ case Product::class:
+ $relation = 'products';
+ break;
+ case Bottle::class:
+ $relation = 'bottles';
+ break;
+ default:
+ return;
+ }
+
+ // Decrease warehouse stock
+ $warehouseItem = $warehouse->{$relation}()
+ ->where("{$relation}.id", $item->restockable_id)
+ ->lockForUpdate()
+ ->withPivot('stock')
+ ->first();
+
+ $warehousePreviousStock = 0;
+
+ if ($warehouseItem) {
+ $warehousePreviousStock = $warehouseItem->pivot->stock ?? 0;
+ }
+
+ if ($warehousePreviousStock < $quantity) {
+ throw new \Exception("Stok {$item->restockable->name} di gudang tidak mencukupi. Tersisa {$warehousePreviousStock}, diminta {$quantity}.");
+ }
+
+ $warehouseNewStock = $warehousePreviousStock - $quantity;
+
+ $warehouse->{$relation}()->updateExistingPivot($item->restockable_id, [
+ 'stock' => $warehouseNewStock,
+ ]);
+
+ // Increase outlet stock
+ $outletItem = $outlet->{$relation}()
+ ->where("{$relation}.id", $item->restockable_id)
+ ->lockForUpdate()
+ ->withPivot('stock')
+ ->first();
+
+ if ($outletItem) {
+ $outletPreviousStock = $outletItem->pivot->stock ?? 0;
+ $outlet->{$relation}()->updateExistingPivot($item->restockable_id, [
+ 'stock' => $outletPreviousStock + $quantity,
+ ]);
+ } else {
+ $outletPreviousStock = 0;
+ $outlet->{$relation}()->attach($item->restockable_id, [
+ 'stock' => $quantity,
+ ]);
+ }
+ $outletNewStock = $outletPreviousStock + $quantity;
+
+ $restock = $item->restock ?? Restock::find($item->restock_id);
+
+ if ($restock) {
+ StockActivityLogService::distribution(
+ restock: $restock,
+ product: $item->restockable,
+ quantity: $quantity,
+ warehousePreviousStock: $warehousePreviousStock,
+ warehouseNewStock: $warehouseNewStock,
+ outletPreviousStock: $outletPreviousStock,
+ outletNewStock: $outletNewStock
+ );
+ }
+ }
+
+ public function reverseStockTransfer($warehouse, $outlet, $item): void
+ {
+ $quantity = $item->quantity;
+
+ switch ($item->restockable_type) {
+ case Perfume::class:
+ $relation = 'perfumes';
+ break;
+ case Product::class:
+ $relation = 'products';
+ break;
+ case Bottle::class:
+ $relation = 'bottles';
+ break;
+ default:
+ return;
+ }
+
+ // Return items to Warehouse
+ $warehousePivot = $warehouse->{$relation}()
+ ->where("{$relation}.id", $item->restockable_id)
+ ->first();
+
+ $currentWarehouseStock = $warehousePivot?->pivot?->stock ?? 0;
+ $newWarehouseStock = $currentWarehouseStock + $quantity;
+
+ if ($warehousePivot) {
+ $warehouse->{$relation}()->updateExistingPivot($item->restockable_id, [
+ 'stock' => $newWarehouseStock,
+ ]);
+ } else {
+ $warehouse->{$relation}()->attach($item->restockable_id, [
+ 'stock' => $quantity,
+ ]);
+ }
+
+ $restock = $item->restock ?? Restock::find($item->restock_id);
+
+ if ($restock) {
+ StockActivityLogService::logWarehouse(
+ logName: StockActivityLogService::LOG_DISTRIBUTION,
+ event: 'increase',
+ description: "Restock #{$restock->hash_id} Deleted: Returned {$quantity} stock to Warehouse.",
+ performedOn: $item->restockable,
+ warehouse: $warehouse,
+ quantityChange: $quantity,
+ previousStock: $currentWarehouseStock,
+ newStock: $newWarehouseStock,
+ extraProperties: ['restock_id' => $restock->id]
+ );
+ }
+
+ // Remove items from Outlet
+ $outletPivot = $outlet->{$relation}()
+ ->where("{$relation}.id", $item->restockable_id)
+ ->first();
+
+ $currentOutletStock = $outletPivot?->pivot?->stock ?? 0;
+ $newOutletStock = max(0, $currentOutletStock - $quantity);
+
+ if ($outletPivot) {
+ $outlet->{$relation}()->updateExistingPivot($item->restockable_id, [
+ 'stock' => $newOutletStock,
+ ]);
+ }
+
+ if ($restock) {
+ StockActivityLogService::logOutlet(
+ logName: StockActivityLogService::LOG_DISTRIBUTION,
+ event: 'decrease',
+ description: "Restock #{$restock->hash_id} Deleted: Removed {$quantity} stock from Outlet.",
+ performedOn: $item->restockable,
+ outlet: $outlet,
+ quantityChange: -1 * abs($quantity),
+ previousStock: $currentOutletStock,
+ newStock: $newOutletStock,
+ extraProperties: ['restock_id' => $restock->id]
+ );
+ }
+ }
+}
diff --git a/app/Traits/Restock/WithRestockUpdateItem.php b/app/Traits/Restock/WithRestockUpdateItem.php
new file mode 100644
index 0000000..5ec0eb1
--- /dev/null
+++ b/app/Traits/Restock/WithRestockUpdateItem.php
@@ -0,0 +1,84 @@
+canOrAbort('create restock');
+
+ if (! $this->form->warehouse_id) {
+ $this->toast('Silakan pilih gudang terlebih dahulu.', 'Gagal', 'warning');
+
+ return;
+ }
+
+ $warehouse = Warehouse::find($this->form->warehouse_id);
+
+ if (! $warehouse) {
+ $this->toast('Gudang tidak ditemukan.', 'Gagal', 'danger');
+
+ return;
+ }
+
+ $item = RestockItem::find($this->form->item_id);
+
+ if ($item) {
+ $newQuantity = parseRupiahToInt($this->form->quantity_edit);
+
+ if ($newQuantity <= 0) {
+ $this->toast('Jumlah harus lebih dari 0.', 'Gagal', 'danger');
+
+ return;
+ }
+
+ $relationName = match ($item->restockable_type) {
+ Perfume::class => 'perfumes',
+ Product::class => 'products',
+ Bottle::class => 'bottles',
+ default => null,
+ };
+
+ if ($relationName) {
+ $warehouseStock = $warehouse->{$relationName}()
+ ->where($item->restockable_type::make()->getTable().'.id', $item->restockable_id)
+ ->first()
+ ?->pivot
+ ?->stock ?? 0;
+
+ if ($newQuantity > $warehouseStock) {
+ $this->toast("Stok tidak mencukupi (Update). Stok di gudang: {$warehouseStock}.", 'Gagal', 'danger');
+
+ return;
+ }
+ }
+
+ $item->update([
+ 'quantity' => $newQuantity,
+ ]);
+ }
+
+ $this->restockItems = $this->loadRestockItems();
+ $this->toast('Item berhasil diperbarui.');
+ $this->dispatch('modal:close', 'form-modal');
+ }
+
+ #[On('modal:open')]
+ public function openModal($item): void
+ {
+ $item = RestockItem::find($item['item']);
+ $this->form->item_id = $item->id;
+ $this->form->quantity_edit = formatCurrencyNumber($item->quantity, '');
+ $this->modalTitle = $item->restockable->name;
+ }
+}
diff --git a/database/factories/RestockFactory.php b/database/factories/RestockFactory.php
new file mode 100644
index 0000000..41a3aa4
--- /dev/null
+++ b/database/factories/RestockFactory.php
@@ -0,0 +1,31 @@
+
+ */
+class RestockFactory extends Factory
+{
+ protected $model = Restock::class;
+
+ /**
+ * Define the model's default state.
+ *
+ * @return array