diff --git a/app/Livewire/Datatable/Manage/StockTransfersTable.php b/app/Livewire/Datatable/Manage/StockTransfersTable.php
new file mode 100644
index 0000000..67c2a0c
--- /dev/null
+++ b/app/Livewire/Datatable/Manage/StockTransfersTable.php
@@ -0,0 +1,78 @@
+searchable(),
+
+ Column::make('Outlet Tujuan', 'destinationOutlet.name')->searchable(),
+
+ Column::make('Tanggal Transfer', 'transfer_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->transferable?->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 stock transfer')) {
+ $actions .= view('components.actions.table.delete', [
+ 'id' => $row->hash,
+ ])->render();
+ }
+
+ return $actions;
+ })
+ ->html()
+ ->hideIf(auth()->user()->cannot('update stock transfer')),
+ ];
+ }
+
+ public function builder(): Builder
+ {
+ return StockTransfer::select('stock_transfers.id', 'source_outlet_id', 'destination_outlet_id', 'transfer_date', 'note')
+ ->with(['sourceOutlet', 'destinationOutlet', 'items', 'items.transferable'])
+ ->whereIn('source_outlet_id', auth()->user()->outlets->pluck('id')->toArray())
+ ->orWhereIn('destination_outlet_id', auth()->user()->outlets->pluck('id')->toArray());
+ }
+}
diff --git a/app/Livewire/Forms/Studio/Manage/StockTransferForm.php b/app/Livewire/Forms/Studio/Manage/StockTransferForm.php
new file mode 100644
index 0000000..3558a11
--- /dev/null
+++ b/app/Livewire/Forms/Studio/Manage/StockTransferForm.php
@@ -0,0 +1,129 @@
+ ['nullable', 'string', 'max:100'],
+ 'transfer_date' => ['required', 'date', 'before_or_equal:today'],
+ 'source_outlet_id' => ['required', Rule::exists('outlets', 'id')],
+ 'destination_outlet_id' => ['required', Rule::exists('outlets', 'id'), 'different:source_outlet_id'],
+ 'image' => ['nullable', 'array', 'max:1'],
+ ];
+ }
+
+ public function validationAttributes(): array
+ {
+ return [
+ 'note' => 'catatan',
+ 'transfer_date' => 'tanggal transfer',
+ 'source_outlet_id' => 'outlet asal',
+ 'destination_outlet_id' => 'outlet tujuan',
+ 'image' => 'gambar',
+ ];
+ }
+
+ public function setStockTransfer(StockTransfer $stockTransfer): void
+ {
+ $this->stockTransfer = $stockTransfer;
+
+ $this->note = $stockTransfer->note;
+ $this->transfer_date = $stockTransfer->transfer_date;
+ $this->source_outlet_id = $stockTransfer->source_outlet_id;
+ $this->destination_outlet_id = $stockTransfer->destination_outlet_id;
+
+ $this->image = $this->mapMediaCollection($stockTransfer->getMedia('image'));
+ }
+
+ public function store(): void
+ {
+ $this->validate();
+
+ $stockTransferItems = $this->loadStockTransferItems();
+
+ DB::transaction(function () use ($stockTransferItems) {
+ $data = $this->prepareSavedData();
+
+ $stockTransfer = StockTransfer::create($data);
+
+ $stockTransfer->load(['sourceOutlet', 'destinationOutlet']);
+
+ $this->uploadMedia($this->image, $stockTransfer, 'image');
+
+ foreach ($stockTransferItems as $item) {
+ if ($item->quantity <= 0) {
+ continue;
+ }
+
+ $item->update(['stock_transfer_id' => $stockTransfer->id]);
+
+ $this->transferStockBetweenOutlets($stockTransfer->sourceOutlet, $stockTransfer->destinationOutlet, $item);
+ }
+ });
+ }
+
+ public function update(): void
+ {
+ $this->validate();
+
+ DB::transaction(function () {
+ $data = $this->prepareSavedData();
+
+ $this->stockTransfer->update($data);
+
+ $this->syncMedia($this->image, $this->stockTransfer, 'image');
+ $this->uploadMedia($this->image, $this->stockTransfer, 'image');
+ });
+ }
+
+ private function prepareSavedData(): array
+ {
+ return [
+ 'note' => $this->note,
+ 'source_outlet_id' => $this->source_outlet_id,
+ 'destination_outlet_id' => $this->destination_outlet_id,
+ 'transfer_date' => $this->transfer_date,
+ ];
+ }
+}
diff --git a/app/Livewire/Studio/Manage/StockTransfer/Create.php b/app/Livewire/Studio/Manage/StockTransfer/Create.php
new file mode 100644
index 0000000..9333202
--- /dev/null
+++ b/app/Livewire/Studio/Manage/StockTransfer/Create.php
@@ -0,0 +1,130 @@
+sourceOutlets = auth()->user()->outlets->pluck('name', 'id')->toArray();
+ $this->destinationOutlets = Outlet::orderBy('name')->pluck('name', 'id')->toArray();
+
+ $this->stockTransferItems = $this->loadStockTransferItems();
+ }
+
+ public function updated($property, $value): void
+ {
+ if ($property === 'form.source_outlet_id' || $property === 'form.destination_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->source_outlet_id || ! $this->form->destination_outlet_id) {
+ return;
+ }
+
+ $sourceOutlet = Outlet::find($this->form->source_outlet_id);
+
+ if (! $sourceOutlet) {
+ return;
+ }
+
+ $this->perfumes = $sourceOutlet->perfumes()
+ ->wherePivot('stock', '>', 0)
+ ->orderBy('name')
+ ->get()
+ ->pluck('name', 'id')
+ ->toArray();
+
+ $this->products = $sourceOutlet->products()
+ ->wherePivot('stock', '>', 0)
+ ->orderBy('name')
+ ->get()
+ ->pluck('name', 'id')
+ ->toArray();
+
+ $this->bottles = $sourceOutlet->bottles()
+ ->wherePivot('stock', '>', 0)
+ ->orderBy('name')
+ ->get()
+ ->pluck('name', 'id')
+ ->toArray();
+ }
+
+ public function save(): void
+ {
+ $this->canOrAbort('create stock transfer');
+
+ if ($this->stockTransferItems->isEmpty()) {
+ $this->toast('Keranjang transfer stok tidak boleh kosong.', 'Gagal', 'danger');
+
+ return;
+ }
+
+ try {
+ $this->form->store();
+ } catch (ValidationException $e) {
+ throw $e;
+ } catch (\Exception $e) {
+ $this->toast($e->getMessage(), 'Gagal', 'danger');
+
+ return;
+ }
+
+ $this->redirectRoute('studio.manage.stock_transfer.index', [
+ 'notification' => 'Transfer stok berhasil diproses.',
+ ], navigate: true);
+ }
+
+ public function render(): View
+ {
+ return view('livewire.studio.manage.stock-transfer.form', [
+ 'pageTitle' => 'Tambah Transfer Stok Antar Outlet',
+ ]);
+ }
+}
diff --git a/app/Livewire/Studio/Manage/StockTransfer/Index.php b/app/Livewire/Studio/Manage/StockTransfer/Index.php
new file mode 100644
index 0000000..6fab4e6
--- /dev/null
+++ b/app/Livewire/Studio/Manage/StockTransfer/Index.php
@@ -0,0 +1,49 @@
+canOrAbort('delete stock transfer');
+
+ DB::transaction(function () use ($stockTransfer) {
+ foreach ($stockTransfer->items as $item) {
+ $this->reverseStockTransferBetweenOutlets($stockTransfer->sourceOutlet, $stockTransfer->destinationOutlet, $item);
+ }
+
+ $stockTransfer->delete();
+ });
+
+ $this->dispatch('refreshDatatable');
+
+ $this->toast('Transfer stok berhasil dihapus.');
+
+ Flux::modals()->close();
+ }
+
+ public function render(): View
+ {
+ return view('livewire.studio.manage.stock-transfer.index', [
+ 'pageTitle' => 'Transfer Stok Antar Outlet',
+ ]);
+ }
+}
diff --git a/app/Models/StockTransfer.php b/app/Models/StockTransfer.php
new file mode 100644
index 0000000..f87d8fe
--- /dev/null
+++ b/app/Models/StockTransfer.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 sourceOutlet(): BelongsTo
+ {
+ return $this->belongsTo(Outlet::class, 'source_outlet_id');
+ }
+
+ public function destinationOutlet(): BelongsTo
+ {
+ return $this->belongsTo(Outlet::class, 'destination_outlet_id');
+ }
+
+ public function items(): HasMany
+ {
+ return $this->hasMany(StockTransferItem::class);
+ }
+}
diff --git a/app/Models/StockTransferItem.php b/app/Models/StockTransferItem.php
new file mode 100644
index 0000000..d75ed5c
--- /dev/null
+++ b/app/Models/StockTransferItem.php
@@ -0,0 +1,46 @@
+ 'int',
+ ];
+ }
+
+ #[Scope]
+ public function currentUser(Builder $query): void
+ {
+ $query->where('user_id', auth()->id());
+ }
+
+ public function stockTransfer(): BelongsTo
+ {
+ return $this->belongsTo(StockTransfer::class);
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function transferable(): MorphTo
+ {
+ return $this->morphTo();
+ }
+}
diff --git a/app/Providers/ViewServiceProvider.php b/app/Providers/ViewServiceProvider.php
index 4ab15df..371a47f 100644
--- a/app/Providers/ViewServiceProvider.php
+++ b/app/Providers/ViewServiceProvider.php
@@ -188,6 +188,13 @@ public function boot(): void
'match' => 'studio.manage.restock.*',
'can' => 'view restock',
],
+ [
+ 'label' => 'Transfer Stok',
+ 'icon' => 'clipboard-document-list',
+ 'route' => 'studio.manage.stock_transfer.index',
+ 'match' => 'studio.manage.stock_transfer.*',
+ 'can' => 'view stock transfer',
+ ],
[
'label' => 'Stock Opname',
'icon' => 'clipboard-document-check',
diff --git a/app/Services/StockActivityLogService.php b/app/Services/StockActivityLogService.php
index 5f76856..f086519 100644
--- a/app/Services/StockActivityLogService.php
+++ b/app/Services/StockActivityLogService.php
@@ -7,6 +7,7 @@
use App\Models\Purchase;
use App\Models\Restock;
use App\Models\StockOpname;
+use App\Models\StockTransfer;
use App\Models\Warehouse;
use Illuminate\Database\Eloquent\Model;
@@ -20,6 +21,8 @@ class StockActivityLogService
public const LOG_STOCK_OPNAME = 'stock_opname';
+ public const LOG_TRANSFER = 'transfer';
+
/**
* Log purchase activity (Incoming to Warehouse).
*/
@@ -159,6 +162,57 @@ public static function stockOpname(
);
}
+ /**
+ * Log transfer activity (Outlet to Outlet).
+ * This logs two events: Out from Source Outlet, In to Destination Outlet.
+ */
+ public static function transfer(
+ StockTransfer $stockTransfer,
+ Model $product,
+ int $quantity,
+ int $sourcePreviousStock,
+ int $sourceNewStock,
+ int $destinationPreviousStock,
+ int $destinationNewStock,
+ ?Model $causedBy = null
+ ): void {
+ // Log Source Outlet Out
+ self::logOutlet(
+ logName: self::LOG_TRANSFER,
+ event: 'decrease',
+ description: "Stock Transfer #{$stockTransfer->hash_id}: Moved {$quantity} stock from Source to Destination Outlet.",
+ performedOn: $product,
+ outlet: $stockTransfer->sourceOutlet,
+ quantityChange: -1 * abs($quantity),
+ previousStock: $sourcePreviousStock,
+ newStock: $sourceNewStock,
+ extraProperties: [
+ 'stock_transfer_id' => $stockTransfer->id,
+ 'stock_transfer_hash' => $stockTransfer->hash_id,
+ 'destination_outlet_id' => $stockTransfer->destination_outlet_id,
+ ],
+ causedBy: $causedBy
+ );
+
+ // Log Destination Outlet In
+ self::logOutlet(
+ logName: self::LOG_TRANSFER,
+ event: 'increase',
+ description: "Stock Transfer #{$stockTransfer->hash_id}: Received {$quantity} stock from Source Outlet.",
+ performedOn: $product,
+ outlet: $stockTransfer->destinationOutlet,
+ quantityChange: abs($quantity),
+ previousStock: $destinationPreviousStock,
+ newStock: $destinationNewStock,
+ extraProperties: [
+ 'stock_transfer_id' => $stockTransfer->id,
+ 'stock_transfer_hash' => $stockTransfer->hash_id,
+ 'source_outlet_id' => $stockTransfer->source_outlet_id,
+ ],
+ causedBy: $causedBy
+ );
+ }
+
/**
* Log stock related activity for Outlet.
*/
diff --git a/app/Traits/StockTransfer/WithStockTransferAddItem.php b/app/Traits/StockTransfer/WithStockTransferAddItem.php
new file mode 100644
index 0000000..1460433
--- /dev/null
+++ b/app/Traits/StockTransfer/WithStockTransferAddItem.php
@@ -0,0 +1,112 @@
+canOrAbort('create stock transfer');
+
+ if (! $this->form->source_outlet_id) {
+ $this->toast('Silakan pilih outlet asal terlebih dahulu.', 'Gagal', 'warning');
+
+ return;
+ }
+
+ $sourceOutlet = Outlet::find($this->form->source_outlet_id);
+
+ if (! $sourceOutlet) {
+ $this->toast('Outlet asal tidak ditemukan.', 'Gagal', 'danger');
+
+ return;
+ }
+
+ $data = match ($type) {
+ 'parfum' => [
+ 'transferable_type' => Perfume::class,
+ 'transferable_id' => $this->form->perfume_id,
+ 'quantity' => parseRupiahToInt($this->form->quantity_perfume),
+ ],
+ 'produk' => [
+ 'transferable_type' => Product::class,
+ 'transferable_id' => $this->form->product_id,
+ 'quantity' => parseRupiahToInt($this->form->quantity_product),
+ ],
+ 'botol' => [
+ 'transferable_type' => Bottle::class,
+ 'transferable_id' => $this->form->bottle_id,
+ 'quantity' => parseRupiahToInt($this->form->quantity_bottle),
+ ],
+ default => null,
+ };
+
+ if (! $data || ! $data['transferable_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) {
+ $sourceStock = $sourceOutlet->{$relationName}()
+ ->where($data['transferable_type']::make()->getTable().'.id', $data['transferable_id'])
+ ->first()
+ ?->pivot
+ ?->stock ?? 0;
+
+ // Is item already in cart?
+ $existing = StockTransferItem::where([
+ 'user_id' => auth()->id(),
+ 'transferable_id' => $data['transferable_id'],
+ 'transferable_type' => $data['transferable_type'],
+ ])->whereNull('stock_transfer_id')->first();
+
+ $currentCartQty = $existing ? $existing->quantity : 0;
+ $requestedQty = $data['quantity'];
+
+ if (($currentCartQty + $requestedQty) > $sourceStock) {
+ $this->toast("Stok tidak mencukupi. Stok di outlet asal: {$sourceStock}. Sudah di keranjang: {$currentCartQty}.", 'Gagal', 'danger');
+
+ return;
+ }
+
+ if ($existing) {
+ $existing->increment('quantity', $requestedQty);
+ } else {
+ StockTransferItem::create(array_merge($data, ['user_id' => auth()->id()]));
+ }
+ }
+
+ $this->resetFormItem($type);
+ $this->stockTransferItems = $this->loadStockTransferItems();
+ $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/StockTransfer/WithStockTransferDeleteItem.php b/app/Traits/StockTransfer/WithStockTransferDeleteItem.php
new file mode 100644
index 0000000..04ede10
--- /dev/null
+++ b/app/Traits/StockTransfer/WithStockTransferDeleteItem.php
@@ -0,0 +1,18 @@
+canOrAbort('create stock transfer');
+
+ $item->delete();
+
+ $this->stockTransferItems = $this->loadStockTransferItems();
+ $this->toast('Item berhasil dihapus dari keranjang.');
+ }
+}
diff --git a/app/Traits/StockTransfer/WithStockTransferItems.php b/app/Traits/StockTransfer/WithStockTransferItems.php
new file mode 100644
index 0000000..b132a42
--- /dev/null
+++ b/app/Traits/StockTransfer/WithStockTransferItems.php
@@ -0,0 +1,19 @@
+stockTransferItems = StockTransferItem::query()
+ ->currentUser()
+ ->where('user_id', auth()->id())
+ ->whereNull('stock_transfer_id')
+ ->latest()
+ ->get();
+ }
+}
diff --git a/app/Traits/StockTransfer/WithStockTransferStock.php b/app/Traits/StockTransfer/WithStockTransferStock.php
new file mode 100644
index 0000000..7867f23
--- /dev/null
+++ b/app/Traits/StockTransfer/WithStockTransferStock.php
@@ -0,0 +1,169 @@
+quantity;
+
+ switch ($item->transferable_type) {
+ case Perfume::class:
+ $relation = 'perfumes';
+ break;
+ case Product::class:
+ $relation = 'products';
+ break;
+ case Bottle::class:
+ $relation = 'bottles';
+ break;
+ default:
+ return;
+ }
+
+ // Decrease source outlet stock
+ $sourceItem = $sourceOutlet->{$relation}()
+ ->where("{$relation}.id", $item->transferable_id)
+ ->lockForUpdate()
+ ->withPivot('stock')
+ ->first();
+
+ $sourcePreviousStock = 0;
+
+ if ($sourceItem) {
+ $sourcePreviousStock = $sourceItem->pivot->stock ?? 0;
+ }
+
+ if ($sourcePreviousStock < $quantity) {
+ throw new \Exception("Stok {$item->transferable->name} di outlet asal tidak mencukupi. Tersisa {$sourcePreviousStock}, diminta {$quantity}.");
+ }
+
+ $sourceNewStock = $sourcePreviousStock - $quantity;
+
+ $sourceOutlet->{$relation}()->updateExistingPivot($item->transferable_id, [
+ 'stock' => $sourceNewStock,
+ ]);
+
+ // Increase destination outlet stock
+ $destinationItem = $destinationOutlet->{$relation}()
+ ->where("{$relation}.id", $item->transferable_id)
+ ->lockForUpdate()
+ ->withPivot('stock')
+ ->first();
+
+ if ($destinationItem) {
+ $destinationPreviousStock = $destinationItem->pivot->stock ?? 0;
+ $destinationOutlet->{$relation}()->updateExistingPivot($item->transferable_id, [
+ 'stock' => $destinationPreviousStock + $quantity,
+ ]);
+ } else {
+ $destinationPreviousStock = 0;
+ $destinationOutlet->{$relation}()->attach($item->transferable_id, [
+ 'stock' => $quantity,
+ ]);
+ }
+ $destinationNewStock = $destinationPreviousStock + $quantity;
+
+ $stockTransfer = $item->stockTransfer ?? StockTransfer::find($item->stock_transfer_id);
+
+ if ($stockTransfer) {
+ StockActivityLogService::transfer(
+ stockTransfer: $stockTransfer,
+ product: $item->transferable,
+ quantity: $quantity,
+ sourcePreviousStock: $sourcePreviousStock,
+ sourceNewStock: $sourceNewStock,
+ destinationPreviousStock: $destinationPreviousStock,
+ destinationNewStock: $destinationNewStock
+ );
+ }
+ }
+
+ public function reverseStockTransferBetweenOutlets($sourceOutlet, $destinationOutlet, $item): void
+ {
+ $quantity = $item->quantity;
+
+ switch ($item->transferable_type) {
+ case Perfume::class:
+ $relation = 'perfumes';
+ break;
+ case Product::class:
+ $relation = 'products';
+ break;
+ case Bottle::class:
+ $relation = 'bottles';
+ break;
+ default:
+ return;
+ }
+
+ // Return items to source outlet
+ $sourcePivot = $sourceOutlet->{$relation}()
+ ->where("{$relation}.id", $item->transferable_id)
+ ->first();
+
+ $currentSourceStock = $sourcePivot?->pivot?->stock ?? 0;
+ $newSourceStock = $currentSourceStock + $quantity;
+
+ if ($sourcePivot) {
+ $sourceOutlet->{$relation}()->updateExistingPivot($item->transferable_id, [
+ 'stock' => $newSourceStock,
+ ]);
+ } else {
+ $sourceOutlet->{$relation}()->attach($item->transferable_id, [
+ 'stock' => $quantity,
+ ]);
+ }
+
+ $stockTransfer = $item->stockTransfer ?? StockTransfer::find($item->stock_transfer_id);
+
+ if ($stockTransfer) {
+ StockActivityLogService::logOutlet(
+ logName: StockActivityLogService::LOG_TRANSFER,
+ event: 'increase',
+ description: "Stock Transfer #{$stockTransfer->hash_id} Deleted: Returned {$quantity} stock to source outlet.",
+ performedOn: $item->transferable,
+ outlet: $sourceOutlet,
+ quantityChange: $quantity,
+ previousStock: $currentSourceStock,
+ newStock: $newSourceStock,
+ extraProperties: ['stock_transfer_id' => $stockTransfer->id]
+ );
+ }
+
+ // Remove items from destination outlet
+ $destinationPivot = $destinationOutlet->{$relation}()
+ ->where("{$relation}.id", $item->transferable_id)
+ ->first();
+
+ $currentDestinationStock = $destinationPivot?->pivot?->stock ?? 0;
+ $newDestinationStock = max(0, $currentDestinationStock - $quantity);
+
+ if ($destinationPivot) {
+ $destinationOutlet->{$relation}()->updateExistingPivot($item->transferable_id, [
+ 'stock' => $newDestinationStock,
+ ]);
+ }
+
+ if ($stockTransfer) {
+ StockActivityLogService::logOutlet(
+ logName: StockActivityLogService::LOG_TRANSFER,
+ event: 'decrease',
+ description: "Stock Transfer #{$stockTransfer->hash_id} Deleted: Removed {$quantity} stock from destination outlet.",
+ performedOn: $item->transferable,
+ outlet: $destinationOutlet,
+ quantityChange: -1 * abs($quantity),
+ previousStock: $currentDestinationStock,
+ newStock: $newDestinationStock,
+ extraProperties: ['stock_transfer_id' => $stockTransfer->id]
+ );
+ }
+ }
+}
diff --git a/app/Traits/StockTransfer/WithStockTransferUpdateItem.php b/app/Traits/StockTransfer/WithStockTransferUpdateItem.php
new file mode 100644
index 0000000..a0fbd91
--- /dev/null
+++ b/app/Traits/StockTransfer/WithStockTransferUpdateItem.php
@@ -0,0 +1,84 @@
+canOrAbort('create stock transfer');
+
+ if (! $this->form->source_outlet_id) {
+ $this->toast('Silakan pilih outlet asal terlebih dahulu.', 'Gagal', 'warning');
+
+ return;
+ }
+
+ $sourceOutlet = Outlet::find($this->form->source_outlet_id);
+
+ if (! $sourceOutlet) {
+ $this->toast('Outlet asal tidak ditemukan.', 'Gagal', 'danger');
+
+ return;
+ }
+
+ $item = StockTransferItem::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->transferable_type) {
+ Perfume::class => 'perfumes',
+ Product::class => 'products',
+ Bottle::class => 'bottles',
+ default => null,
+ };
+
+ if ($relationName) {
+ $sourceStock = $sourceOutlet->{$relationName}()
+ ->where($item->transferable_type::make()->getTable().'.id', $item->transferable_id)
+ ->first()
+ ?->pivot
+ ?->stock ?? 0;
+
+ if ($newQuantity > $sourceStock) {
+ $this->toast("Stok tidak mencukupi (Update). Stok di outlet asal: {$sourceStock}.", 'Gagal', 'danger');
+
+ return;
+ }
+ }
+
+ $item->update([
+ 'quantity' => $newQuantity,
+ ]);
+ }
+
+ $this->stockTransferItems = $this->loadStockTransferItems();
+ $this->toast('Item berhasil diperbarui.');
+ $this->dispatch('modal:close', 'form-modal');
+ }
+
+ #[On('modal:open')]
+ public function openModal($item): void
+ {
+ $item = StockTransferItem::find($item['item']);
+ $this->form->item_id = $item->id;
+ $this->form->quantity_edit = formatCurrencyNumber($item->quantity, '');
+ $this->modalTitle = $item->transferable->name;
+ }
+}
diff --git a/database/migrations/2026_01_09_212348_create_stock_transfers_table.php b/database/migrations/2026_01_09_212348_create_stock_transfers_table.php
new file mode 100644
index 0000000..4b4b3e0
--- /dev/null
+++ b/database/migrations/2026_01_09_212348_create_stock_transfers_table.php
@@ -0,0 +1,33 @@
+id();
+ $table->foreignId('source_outlet_id')->constrained('outlets')->cascadeOnDelete();
+ $table->foreignId('destination_outlet_id')->constrained('outlets')->cascadeOnDelete();
+ $table->date('transfer_date');
+ $table->string('note', 100)->nullable();
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
+ $table->softDeletes();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('stock_transfers');
+ }
+};
diff --git a/database/migrations/2026_01_09_212415_create_stock_transfer_items_table.php b/database/migrations/2026_01_09_212415_create_stock_transfer_items_table.php
new file mode 100644
index 0000000..5ad327d
--- /dev/null
+++ b/database/migrations/2026_01_09_212415_create_stock_transfer_items_table.php
@@ -0,0 +1,33 @@
+id();
+ $table->foreignId('stock_transfer_id')->nullable()->constrained()->cascadeOnDelete();
+ $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+ $table->morphs('transferable');
+ $table->unsignedInteger('quantity');
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
+ $table->softDeletes();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('stock_transfer_items');
+ }
+};
diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php
index 9c25c00..1b902c7 100644
--- a/database/seeders/RolePermissionSeeder.php
+++ b/database/seeders/RolePermissionSeeder.php
@@ -139,6 +139,11 @@ public function run(): void
'update restock',
'delete restock',
+ 'view stock transfer',
+ 'create stock transfer',
+ 'update stock transfer',
+ 'delete stock transfer',
+
'view reward',
'create reward',
'update reward',
@@ -399,6 +404,11 @@ public function run(): void
'update restock',
'delete restock',
+ 'view stock transfer',
+ 'create stock transfer',
+ 'update stock transfer',
+ 'delete stock transfer',
+
'view reward',
'create reward',
'update reward',
diff --git a/resources/views/livewire/studio/manage/stock-transfer/form.blade.php b/resources/views/livewire/studio/manage/stock-transfer/form.blade.php
new file mode 100644
index 0000000..bf04e2b
--- /dev/null
+++ b/resources/views/livewire/studio/manage/stock-transfer/form.blade.php
@@ -0,0 +1,213 @@
+