feat: implement retail stock transfer functionality with new controller, request validation, and service logic, while updating related models and UI components
This commit is contained in:
parent
aeb7d1f292
commit
8f66502f25
@ -226,7 +226,7 @@ ### Controller
|
|||||||
PurchaseDraftItemController.php
|
PurchaseDraftItemController.php
|
||||||
Stock/
|
Stock/
|
||||||
StockController.php
|
StockController.php
|
||||||
StockRetailController.php
|
RetailStockController.php
|
||||||
StokOpnameController.php ← modul dengan 1 controller tetap di level parent
|
StokOpnameController.php ← modul dengan 1 controller tetap di level parent
|
||||||
OwnerVerificationController.php
|
OwnerVerificationController.php
|
||||||
```
|
```
|
||||||
|
|||||||
@ -13,7 +13,7 @@ enum OwnerVerificationAction: string
|
|||||||
case DELETE = 'delete';
|
case DELETE = 'delete';
|
||||||
case TOGGLE_STATUS = 'toggle_status';
|
case TOGGLE_STATUS = 'toggle_status';
|
||||||
case STOCK_VERIFY = 'stock_verify';
|
case STOCK_VERIFY = 'stock_verify';
|
||||||
case STOCK_RETAIL_TRANSFER = 'stock_retail_transfer';
|
case RETAIL_STOCK_TRANSFER = 'retail_stock_transfer';
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
@ -23,7 +23,7 @@ public function label(): string
|
|||||||
self::DELETE => 'Hapus',
|
self::DELETE => 'Hapus',
|
||||||
self::TOGGLE_STATUS => 'Ubah Status',
|
self::TOGGLE_STATUS => 'Ubah Status',
|
||||||
self::STOCK_VERIFY => 'Verifikasi Stok',
|
self::STOCK_VERIFY => 'Verifikasi Stok',
|
||||||
self::STOCK_RETAIL_TRANSFER => 'Transfer Stok Ecer',
|
self::RETAIL_STOCK_TRANSFER => 'Transfer Stok Ecer',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,21 +3,21 @@
|
|||||||
namespace App\Http\Controllers\Admin\Manage\Stock;
|
namespace App\Http\Controllers\Admin\Manage\Stock;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\Manage\StockRetailTransferRequest;
|
use App\Http\Requests\Admin\Manage\RetailStockTransferRequest;
|
||||||
use App\Services\Manage\StockRetailService;
|
use App\Services\Manage\RetailStockService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
class StockRetailController extends Controller
|
class RetailStockController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly StockRetailService $stockRetailService,
|
private readonly RetailStockService $retailStockService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function transfer(StockRetailTransferRequest $request): JsonResponse
|
public function transfer(RetailStockTransferRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
|
|
||||||
$this->stockRetailService->transfer(
|
$this->retailStockService->transfer(
|
||||||
$validated['product_variant_id'],
|
$validated['product_variant_id'],
|
||||||
$validated['quantity'],
|
$validated['quantity'],
|
||||||
$request->user(),
|
$request->user(),
|
||||||
@ -6,7 +6,7 @@
|
|||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class StockRetailTransferRequest extends FormRequest
|
class RetailStockTransferRequest extends FormRequest
|
||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
@ -42,7 +42,7 @@ public function rules(): array
|
|||||||
],
|
],
|
||||||
'variants.*.name' => ['required', 'string', 'max:200'],
|
'variants.*.name' => ['required', 'string', 'max:200'],
|
||||||
'variants.*.stock' => ['required', 'integer', 'min:0'],
|
'variants.*.stock' => ['required', 'integer', 'min:0'],
|
||||||
'variants.*.stock_retail' => ['required', 'integer', 'min:0'],
|
'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
|
||||||
...$this->variantImageRules(),
|
...$this->variantImageRules(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -60,7 +60,7 @@ public function attributes(): array
|
|||||||
'variants' => 'Varian',
|
'variants' => 'Varian',
|
||||||
'variants.*.name' => 'Nama Varian',
|
'variants.*.name' => 'Nama Varian',
|
||||||
'variants.*.stock' => 'Stok',
|
'variants.*.stock' => 'Stok',
|
||||||
'variants.*.stock_retail' => 'Stok Ecer',
|
'variants.*.retail_stock' => 'Stok Ecer',
|
||||||
...$this->variantImageAttributes('variants', 'Foto Varian'),
|
...$this->variantImageAttributes('variants', 'Foto Varian'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,7 +65,7 @@ public function totalRejectStockFormatted(): Attribute
|
|||||||
public function totalRetailStockFormatted(): Attribute
|
public function totalRetailStockFormatted(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
get: fn () => number_format($this->variants->sum('stock_retail'), 0, ',', '.'),
|
get: fn () => number_format($this->variants->sum('retail_stock'), 0, ',', '.'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
use Spatie\MediaLibrary\HasMedia;
|
use Spatie\MediaLibrary\HasMedia;
|
||||||
|
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
#[Appends(['stock_formatted', 'reject_stock_formatted', 'stock_retail_formatted'])]
|
#[Appends(['stock_formatted', 'reject_stock_formatted', 'retail_stock_formatted'])]
|
||||||
class ProductVariant extends Model implements HasMedia
|
class ProductVariant extends Model implements HasMedia
|
||||||
{
|
{
|
||||||
// 1. Use Trait
|
// 1. Use Trait
|
||||||
@ -29,7 +29,7 @@ protected function casts(): array
|
|||||||
return [
|
return [
|
||||||
'reject_stock' => 'integer',
|
'reject_stock' => 'integer',
|
||||||
'stock' => 'integer',
|
'stock' => 'integer',
|
||||||
'stock_retail' => 'integer',
|
'retail_stock' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,10 +48,10 @@ public function stockFormatted(): Attribute
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function stockRetailFormatted(): Attribute
|
public function retailStockFormatted(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
get: fn () => number_format($this->stock_retail, 0, ',', '.'),
|
get: fn () => number_format($this->retail_stock, 0, ',', '.'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -50,8 +50,10 @@ public function stockFormatted(): Attribute
|
|||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
get: function () {
|
get: function () {
|
||||||
$formatted = rtrim(rtrim(number_format((float) $this->stock, 4, ',', '.'), '0'), ',');
|
$formatted = rtrim(rtrim(number_format((float) $this->stock, 4, ',', '.'), '0'), ',');
|
||||||
|
$unitAbbreviation = $this->attributes['unit_abbreviation']
|
||||||
|
?? $this->rawMaterial?->unit?->abbreviation();
|
||||||
|
|
||||||
return "{$formatted} {$this->rawMaterial->unit->abbreviation()}";
|
return "{$formatted} {$unitAbbreviation}";
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,9 @@
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
class StockRetailHistory extends Model
|
class RetailStockHistory extends Model
|
||||||
{
|
{
|
||||||
protected $table = 'stock_retail_histories';
|
protected $table = 'retail_stock_histories';
|
||||||
|
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|
||||||
@ -21,8 +21,8 @@ protected function casts(): array
|
|||||||
'quantity' => 'integer',
|
'quantity' => 'integer',
|
||||||
'stock_after' => 'integer',
|
'stock_after' => 'integer',
|
||||||
'stock_before' => 'integer',
|
'stock_before' => 'integer',
|
||||||
'stock_retail_after' => 'integer',
|
'retail_stock_after' => 'integer',
|
||||||
'stock_retail_before' => 'integer',
|
'retail_stock_before' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,7 +117,7 @@ public function rawMaterialCatalog(?Cutting $cutting = null, ?User $user = null)
|
|||||||
->with([
|
->with([
|
||||||
'prices' => fn ($query) => $query
|
'prices' => fn ($query) => $query
|
||||||
->orderBy('created_at')
|
->orderBy('created_at')
|
||||||
->with(['rawMaterial:id,unit', 'media']),
|
->with('media'),
|
||||||
])
|
])
|
||||||
->where(function (Builder $query) use ($selectedPriceIds): void {
|
->where(function (Builder $query) use ($selectedPriceIds): void {
|
||||||
$query->active();
|
$query->active();
|
||||||
@ -132,11 +132,10 @@ public function rawMaterialCatalog(?Cutting $cutting = null, ?User $user = null)
|
|||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get()
|
->get()
|
||||||
->each(function (RawMaterial $rawMaterial): void {
|
->each(function (RawMaterial $rawMaterial): void {
|
||||||
$rawMaterial->prices->each(function (RawMaterialPrice $price): void {
|
$rawMaterial->prices->each(function (RawMaterialPrice $price) use ($rawMaterial): void {
|
||||||
$price->setAttribute(
|
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
|
||||||
'images',
|
$price->setAttribute('unit_abbreviation', $rawMaterial->unit->abbreviation());
|
||||||
MediaPresenter::collection($price, 'images'),
|
$price->unsetRelation('rawMaterial');
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,7 +52,7 @@ public function stockColumn(ProductStockQuality $quality): string
|
|||||||
return match ($quality) {
|
return match ($quality) {
|
||||||
ProductStockQuality::GOOD => 'stock',
|
ProductStockQuality::GOOD => 'stock',
|
||||||
ProductStockQuality::REJECT => 'reject_stock',
|
ProductStockQuality::REJECT => 'reject_stock',
|
||||||
ProductStockQuality::RETAIL => 'stock_retail',
|
ProductStockQuality::RETAIL => 'retail_stock',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -36,7 +36,7 @@ public function __construct(
|
|||||||
private readonly PurchaseService $purchaseService,
|
private readonly PurchaseService $purchaseService,
|
||||||
private readonly PushNotificationService $pushNotificationService,
|
private readonly PushNotificationService $pushNotificationService,
|
||||||
private readonly MarketplaceService $marketplaceService,
|
private readonly MarketplaceService $marketplaceService,
|
||||||
private readonly StockRetailService $stockRetailService,
|
private readonly RetailStockService $retailStockService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function hasPendingMarketplaceVerification(): bool
|
public function hasPendingMarketplaceVerification(): bool
|
||||||
@ -283,7 +283,7 @@ private function rejectVerificationRequest(OwnerVerificationRequest $request): v
|
|||||||
RawMaterial::class => $this->rawMaterialService->rejectVerificationRequest($request),
|
RawMaterial::class => $this->rawMaterialService->rejectVerificationRequest($request),
|
||||||
Purchase::class => $this->purchaseService->rejectVerificationRequest($request),
|
Purchase::class => $this->purchaseService->rejectVerificationRequest($request),
|
||||||
MarketplaceSettings::class => null,
|
MarketplaceSettings::class => null,
|
||||||
ProductVariant::class => $this->stockRetailService->rejectStockRetailTransfer($request),
|
ProductVariant::class => $this->retailStockService->rejectRetailStockTransfer($request),
|
||||||
default => throw ValidationException::withMessages([
|
default => throw ValidationException::withMessages([
|
||||||
'subject_type' => 'Tipe data verifikasi tidak didukung.',
|
'subject_type' => 'Tipe data verifikasi tidak didukung.',
|
||||||
]),
|
]),
|
||||||
@ -297,7 +297,7 @@ private function applyVerificationRequest(OwnerVerificationRequest $request): vo
|
|||||||
RawMaterial::class => $this->rawMaterialService->applyVerificationRequest($request),
|
RawMaterial::class => $this->rawMaterialService->applyVerificationRequest($request),
|
||||||
Purchase::class => $this->purchaseService->applyVerificationRequest($request),
|
Purchase::class => $this->purchaseService->applyVerificationRequest($request),
|
||||||
MarketplaceSettings::class => $this->marketplaceService->applyVerificationRequest($request),
|
MarketplaceSettings::class => $this->marketplaceService->applyVerificationRequest($request),
|
||||||
ProductVariant::class => $this->stockRetailService->applyStockRetailTransfer($request),
|
ProductVariant::class => $this->retailStockService->applyRetailStockTransfer($request),
|
||||||
default => throw ValidationException::withMessages([
|
default => throw ValidationException::withMessages([
|
||||||
'subject_type' => 'Tipe data verifikasi tidak didukung.',
|
'subject_type' => 'Tipe data verifikasi tidak didukung.',
|
||||||
]),
|
]),
|
||||||
|
|||||||
@ -97,7 +97,7 @@ public function catalogItems(?Purchase $purchase = null, ?User $user = null): Co
|
|||||||
->with([
|
->with([
|
||||||
'prices' => fn ($query) => $query
|
'prices' => fn ($query) => $query
|
||||||
->orderBy('created_at')
|
->orderBy('created_at')
|
||||||
->with(['rawMaterial:id,unit', 'media']),
|
->with('media'),
|
||||||
])
|
])
|
||||||
->where(function (Builder $query) use ($purchasePriceIds): void {
|
->where(function (Builder $query) use ($purchasePriceIds): void {
|
||||||
$query->active();
|
$query->active();
|
||||||
@ -112,11 +112,10 @@ public function catalogItems(?Purchase $purchase = null, ?User $user = null): Co
|
|||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get()
|
->get()
|
||||||
->each(function (RawMaterial $rawMaterial): void {
|
->each(function (RawMaterial $rawMaterial): void {
|
||||||
$rawMaterial->prices->each(function (RawMaterialPrice $price): void {
|
$rawMaterial->prices->each(function (RawMaterialPrice $price) use ($rawMaterial): void {
|
||||||
$price->setAttribute(
|
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
|
||||||
'images',
|
$price->setAttribute('unit_abbreviation', $rawMaterial->unit->abbreviation());
|
||||||
MediaPresenter::collection($price, 'images'),
|
$price->unsetRelation('rawMaterial');
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,14 +7,14 @@
|
|||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Models\OwnerVerificationRequest;
|
use App\Models\OwnerVerificationRequest;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Models\StockRetailHistory;
|
use App\Models\RetailStockHistory;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\System\PushNotificationService;
|
use App\Services\System\PushNotificationService;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class StockRetailService
|
class RetailStockService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PushNotificationService $pushNotificationService,
|
private readonly PushNotificationService $pushNotificationService,
|
||||||
@ -47,7 +47,7 @@ public function transfer(int $variantId, int $quantity, User $user, ?string $not
|
|||||||
$existingPending = OwnerVerificationRequest::query()
|
$existingPending = OwnerVerificationRequest::query()
|
||||||
->where('subject_type', ProductVariant::class)
|
->where('subject_type', ProductVariant::class)
|
||||||
->where('subject_id', $variant->id)
|
->where('subject_id', $variant->id)
|
||||||
->where('action', OwnerVerificationAction::STOCK_RETAIL_TRANSFER)
|
->where('action', OwnerVerificationAction::RETAIL_STOCK_TRANSFER)
|
||||||
->where('status', OwnerVerificationStatus::PENDING)
|
->where('status', OwnerVerificationStatus::PENDING)
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
@ -59,7 +59,7 @@ public function transfer(int $variantId, int $quantity, User $user, ?string $not
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$request = OwnerVerificationRequest::create([
|
$request = OwnerVerificationRequest::create([
|
||||||
'action' => OwnerVerificationAction::STOCK_RETAIL_TRANSFER,
|
'action' => OwnerVerificationAction::RETAIL_STOCK_TRANSFER,
|
||||||
'status' => OwnerVerificationStatus::PENDING,
|
'status' => OwnerVerificationStatus::PENDING,
|
||||||
'subject_type' => ProductVariant::class,
|
'subject_type' => ProductVariant::class,
|
||||||
'subject_id' => $variant->id,
|
'subject_id' => $variant->id,
|
||||||
@ -67,12 +67,12 @@ public function transfer(int $variantId, int $quantity, User $user, ?string $not
|
|||||||
'payload' => [
|
'payload' => [
|
||||||
'old' => [
|
'old' => [
|
||||||
'stock' => $variant->stock,
|
'stock' => $variant->stock,
|
||||||
'stock_retail' => $variant->stock_retail,
|
'retail_stock' => $variant->retail_stock,
|
||||||
],
|
],
|
||||||
'new' => [
|
'new' => [
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'stock' => $variant->stock - $quantity,
|
'stock' => $variant->stock - $quantity,
|
||||||
'stock_retail' => $variant->stock_retail + $quantity,
|
'retail_stock' => $variant->retail_stock + $quantity,
|
||||||
'notes' => $notes,
|
'notes' => $notes,
|
||||||
'variant_name' => $variant->name,
|
'variant_name' => $variant->name,
|
||||||
'product_name' => $variant->product?->name ?? '-',
|
'product_name' => $variant->product?->name ?? '-',
|
||||||
@ -110,7 +110,7 @@ public function transfer(int $variantId, int $quantity, User $user, ?string $not
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function applyStockRetailTransfer(OwnerVerificationRequest $request): void
|
public function applyRetailStockTransfer(OwnerVerificationRequest $request): void
|
||||||
{
|
{
|
||||||
$payload = $request->payload ?? [];
|
$payload = $request->payload ?? [];
|
||||||
$newData = $payload['new'] ?? [];
|
$newData = $payload['new'] ?? [];
|
||||||
@ -138,26 +138,26 @@ private function executeTransfer(ProductVariant $variant, int $quantity, User $u
|
|||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$stockBefore = $variant->stock;
|
$stockBefore = $variant->stock;
|
||||||
$stockRetailBefore = $variant->stock_retail;
|
$retailStockBefore = $variant->retail_stock;
|
||||||
|
|
||||||
$variant->decrement('stock', $quantity);
|
$variant->decrement('stock', $quantity);
|
||||||
$variant->increment('stock_retail', $quantity);
|
$variant->increment('retail_stock', $quantity);
|
||||||
|
|
||||||
StockRetailHistory::create([
|
RetailStockHistory::create([
|
||||||
'product_variant_id' => $variant->id,
|
'product_variant_id' => $variant->id,
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'stock_before' => $stockBefore,
|
'stock_before' => $stockBefore,
|
||||||
'stock_retail_before' => $stockRetailBefore,
|
'retail_stock_before' => $retailStockBefore,
|
||||||
'stock_after' => $stockBefore - $quantity,
|
'stock_after' => $stockBefore - $quantity,
|
||||||
'stock_retail_after' => $stockRetailBefore + $quantity,
|
'retail_stock_after' => $retailStockBefore + $quantity,
|
||||||
'notes' => $notes,
|
'notes' => $notes,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rejectStockRetailTransfer(OwnerVerificationRequest $request): void
|
public function rejectRetailStockTransfer(OwnerVerificationRequest $request): void
|
||||||
{
|
{
|
||||||
// No-op: nothing was changed yet, so nothing to rollback.
|
// No-op: nothing was changed yet, so nothing to rollback.
|
||||||
}
|
}
|
||||||
@ -74,12 +74,12 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
|
|||||||
|
|
||||||
$pendingRequest = $product->pendingOwnerVerificationRequest;
|
$pendingRequest = $product->pendingOwnerVerificationRequest;
|
||||||
|
|
||||||
// Also check for pending stock retail transfer on variants
|
// Also check for pending retail stock transfer on variants
|
||||||
if ($pendingRequest === null) {
|
if ($pendingRequest === null) {
|
||||||
$pendingRequest = OwnerVerificationRequest::query()
|
$pendingRequest = OwnerVerificationRequest::query()
|
||||||
->where('subject_type', ProductVariant::class)
|
->where('subject_type', ProductVariant::class)
|
||||||
->whereIn('subject_id', $product->variants->pluck('id'))
|
->whereIn('subject_id', $product->variants->pluck('id'))
|
||||||
->where('action', OwnerVerificationAction::STOCK_RETAIL_TRANSFER)
|
->where('action', OwnerVerificationAction::RETAIL_STOCK_TRANSFER)
|
||||||
->pending()
|
->pending()
|
||||||
->latest()
|
->latest()
|
||||||
->first();
|
->first();
|
||||||
@ -132,7 +132,7 @@ public function create(array $validated, User $user): void
|
|||||||
$variant = $product->variants()->create([
|
$variant = $product->variants()->create([
|
||||||
'name' => $variantData['name'],
|
'name' => $variantData['name'],
|
||||||
'stock' => $variantData['stock'],
|
'stock' => $variantData['stock'],
|
||||||
'stock_retail' => $variantData['stock_retail'],
|
'retail_stock' => $variantData['retail_stock'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->syncVariantImages($variant, $variantData, $index);
|
$this->syncVariantImages($variant, $variantData, $index);
|
||||||
@ -195,7 +195,7 @@ public function update(Product $product, array $validated, User $user): void
|
|||||||
$variant = $product->variants()->create([
|
$variant = $product->variants()->create([
|
||||||
'name' => $variantData['name'],
|
'name' => $variantData['name'],
|
||||||
'stock' => $variantData['stock'],
|
'stock' => $variantData['stock'],
|
||||||
'stock_retail' => $variantData['stock_retail'],
|
'retail_stock' => $variantData['retail_stock'],
|
||||||
]);
|
]);
|
||||||
$this->syncVariantImages($variant, $variantData, $index);
|
$this->syncVariantImages($variant, $variantData, $index);
|
||||||
}
|
}
|
||||||
@ -436,7 +436,7 @@ private function applyPayloadToProduct(
|
|||||||
$variant->update([
|
$variant->update([
|
||||||
'name' => $variantData['name'],
|
'name' => $variantData['name'],
|
||||||
'stock' => $variantData['stock'],
|
'stock' => $variantData['stock'],
|
||||||
'stock_retail' => $variantData['stock_retail'],
|
'retail_stock' => $variantData['retail_stock'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($verificationRequest !== null) {
|
if ($verificationRequest !== null) {
|
||||||
@ -449,7 +449,7 @@ private function applyPayloadToProduct(
|
|||||||
$variant = $product->variants()->create([
|
$variant = $product->variants()->create([
|
||||||
'name' => $variantData['name'],
|
'name' => $variantData['name'],
|
||||||
'stock' => $variantData['stock'],
|
'stock' => $variantData['stock'],
|
||||||
'stock_retail' => $variantData['stock_retail'],
|
'retail_stock' => $variantData['retail_stock'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($verificationRequest !== null) {
|
if ($verificationRequest !== null) {
|
||||||
@ -595,7 +595,7 @@ private function snapshotProduct(Product $product): array
|
|||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
'name' => $variant->name,
|
'name' => $variant->name,
|
||||||
'stock' => $variant->stock,
|
'stock' => $variant->stock,
|
||||||
'stock_retail' => $variant->stock_retail,
|
'retail_stock' => $variant->retail_stock,
|
||||||
])
|
])
|
||||||
->all(),
|
->all(),
|
||||||
]);
|
]);
|
||||||
@ -623,7 +623,7 @@ private function buildPayloadFromValidated(array $validated): array
|
|||||||
'id' => $variantData['id'] ?? null,
|
'id' => $variantData['id'] ?? null,
|
||||||
'name' => $variantData['name'],
|
'name' => $variantData['name'],
|
||||||
'stock' => $variantData['stock'],
|
'stock' => $variantData['stock'],
|
||||||
'stock_retail' => $variantData['stock_retail'],
|
'retail_stock' => $variantData['retail_stock'],
|
||||||
'remove_media_ids' => $variantData['remove_media_ids'] ?? [],
|
'remove_media_ids' => $variantData['remove_media_ids'] ?? [],
|
||||||
])
|
])
|
||||||
->all(),
|
->all(),
|
||||||
|
|||||||
@ -35,7 +35,7 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $st
|
|||||||
'pendingOwnerVerificationRequest',
|
'pendingOwnerVerificationRequest',
|
||||||
'prices' => fn ($query) => $query
|
'prices' => fn ($query) => $query
|
||||||
->orderBy('created_at')
|
->orderBy('created_at')
|
||||||
->with(['rawMaterial:id,unit', 'media']),
|
->with('media'),
|
||||||
])
|
])
|
||||||
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
|
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
|
||||||
$search = $tableQuery['search'];
|
$search = $tableQuery['search'];
|
||||||
@ -73,11 +73,10 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $st
|
|||||||
->paginate(25)
|
->paginate(25)
|
||||||
->withQueryString()
|
->withQueryString()
|
||||||
->through(function (RawMaterial $rawMaterial) {
|
->through(function (RawMaterial $rawMaterial) {
|
||||||
$rawMaterial->prices->each(function (RawMaterialPrice $price): void {
|
$rawMaterial->prices->each(function (RawMaterialPrice $price) use ($rawMaterial): void {
|
||||||
$price->setAttribute(
|
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
|
||||||
'images',
|
$price->setAttribute('unit_abbreviation', $rawMaterial->unit->abbreviation());
|
||||||
MediaPresenter::collection($price, 'images'),
|
$price->unsetRelation('rawMaterial');
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$pendingRequest = $rawMaterial->pendingOwnerVerificationRequest;
|
$pendingRequest = $rawMaterial->pendingOwnerVerificationRequest;
|
||||||
@ -101,8 +100,10 @@ public function findForEdit(RawMaterial $rawMaterial): RawMaterial
|
|||||||
'prices' => fn ($query) => $query->orderBy('created_at')->with('media'),
|
'prices' => fn ($query) => $query->orderBy('created_at')->with('media'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$rawMaterial->prices->each(function (RawMaterialPrice $price): void {
|
$rawMaterial->prices->each(function (RawMaterialPrice $price) use ($rawMaterial): void {
|
||||||
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
|
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
|
||||||
|
$price->setAttribute('unit_abbreviation', $rawMaterial->unit->abbreviation());
|
||||||
|
$price->unsetRelation('rawMaterial');
|
||||||
});
|
});
|
||||||
|
|
||||||
return $rawMaterial;
|
return $rawMaterial;
|
||||||
|
|||||||
@ -499,7 +499,7 @@ public function getProductStock(): array
|
|||||||
->selectRaw('
|
->selectRaw('
|
||||||
SUM(product_variants.stock) as total_stock,
|
SUM(product_variants.stock) as total_stock,
|
||||||
SUM(product_variants.reject_stock) as total_reject,
|
SUM(product_variants.reject_stock) as total_reject,
|
||||||
SUM(product_variants.stock_retail) as total_retail,
|
SUM(product_variants.retail_stock) as total_retail,
|
||||||
COUNT(product_variants.id) as total_variants,
|
COUNT(product_variants.id) as total_variants,
|
||||||
COUNT(DISTINCT products.id) as total_products
|
COUNT(DISTINCT products.id) as total_products
|
||||||
')
|
')
|
||||||
|
|||||||
@ -77,7 +77,7 @@ private static function label(string $field): string
|
|||||||
'prices' => 'Varian Harga',
|
'prices' => 'Varian Harga',
|
||||||
'quantity' => 'Jumlah Transfer',
|
'quantity' => 'Jumlah Transfer',
|
||||||
'stock' => 'Stok Bagus',
|
'stock' => 'Stok Bagus',
|
||||||
'stock_retail' => 'Stok Ecer',
|
'retail_stock' => 'Stok Ecer',
|
||||||
'variant_name' => 'Varian',
|
'variant_name' => 'Varian',
|
||||||
'product_name' => 'Produk',
|
'product_name' => 'Produk',
|
||||||
'tiktok_shop_platform_commission' => 'TikTok Shop - Komisi Platform',
|
'tiktok_shop_platform_commission' => 'TikTok Shop - Komisi Platform',
|
||||||
@ -186,7 +186,7 @@ private static function presentValue(string $field, mixed $value): mixed
|
|||||||
return implode(', ', $value);
|
return implode(', ', $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($field, ['quantity', 'stock', 'stock_retail'], true)) {
|
if (in_array($field, ['quantity', 'stock', 'retail_stock'], true)) {
|
||||||
return (int) $value;
|
return (int) $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ public function up(): void
|
|||||||
$table->string('name', 200);
|
$table->string('name', 200);
|
||||||
$table->unsignedInteger('stock')->default(0);
|
$table->unsignedInteger('stock')->default(0);
|
||||||
$table->unsignedInteger('reject_stock')->default(0);
|
$table->unsignedInteger('reject_stock')->default(0);
|
||||||
$table->unsignedInteger('stock_retail')->default(0);
|
$table->unsignedInteger('retail_stock')->default(0);
|
||||||
|
|
||||||
$table->timestamp('created_at')->useCurrent();
|
$table->timestamp('created_at')->useCurrent();
|
||||||
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
{
|
{
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::create('stock_retail_histories', function (Blueprint $table) {
|
Schema::create('retail_stock_histories', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
|
|
||||||
$table->foreignId('product_variant_id')->constrained('product_variants')->cascadeOnDelete();
|
$table->foreignId('product_variant_id')->constrained('product_variants')->cascadeOnDelete();
|
||||||
@ -16,9 +16,9 @@ public function up(): void
|
|||||||
|
|
||||||
$table->unsignedInteger('quantity');
|
$table->unsignedInteger('quantity');
|
||||||
$table->unsignedInteger('stock_before');
|
$table->unsignedInteger('stock_before');
|
||||||
$table->unsignedInteger('stock_retail_before');
|
$table->unsignedInteger('retail_stock_before');
|
||||||
$table->unsignedInteger('stock_after');
|
$table->unsignedInteger('stock_after');
|
||||||
$table->unsignedInteger('stock_retail_after');
|
$table->unsignedInteger('retail_stock_after');
|
||||||
$table->string('notes')->nullable();
|
$table->string('notes')->nullable();
|
||||||
|
|
||||||
$table->timestamp('created_at')->useCurrent();
|
$table->timestamp('created_at')->useCurrent();
|
||||||
@ -27,6 +27,6 @@ public function up(): void
|
|||||||
|
|
||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
Schema::dropIfExists('stock_retail_histories');
|
Schema::dropIfExists('retail_stock_histories');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
33
resources/js/components/button/RowTransferAction.vue
Normal file
33
resources/js/components/button/RowTransferAction.vue
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ArrowLeftRight } from '@lucide/vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
tooltip?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
tooltipClass?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-8"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="emit('click')"
|
||||||
|
>
|
||||||
|
<ArrowLeftRight class="size-4" />
|
||||||
|
<span class="sr-only">{{ tooltip || 'Transfer Stok Ecer' }}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent :class="tooltipClass">{{ tooltip || 'Transfer Stok Ecer' }}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</template>
|
||||||
@ -7,5 +7,6 @@ export { default as RowAdjustAction } from './RowAdjustAction.vue';
|
|||||||
export { default as RowStatusAction } from './RowStatusAction.vue';
|
export { default as RowStatusAction } from './RowStatusAction.vue';
|
||||||
export { default as RowPrintAction } from './RowPrintAction.vue';
|
export { default as RowPrintAction } from './RowPrintAction.vue';
|
||||||
export { default as RowDetailAction } from './RowDetailAction.vue';
|
export { default as RowDetailAction } from './RowDetailAction.vue';
|
||||||
|
export { default as RowTransferAction } from './RowTransferAction.vue';
|
||||||
export { default as CreateButton } from './CreateButton.vue';
|
export { default as CreateButton } from './CreateButton.vue';
|
||||||
export { default as BackButton } from './BackButton.vue';
|
export { default as BackButton } from './BackButton.vue';
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ArrowLeftRight, Save } from '@lucide/vue';
|
import { Save } from '@lucide/vue';
|
||||||
import { reactive, ref, watch } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { NumberInput } from '@/components/form/number-input';
|
import { NumberInput } from '@/components/form/number-input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -18,9 +18,24 @@ import {
|
|||||||
FieldLabel,
|
FieldLabel,
|
||||||
FieldSet,
|
FieldSet,
|
||||||
} from '@/components/ui/field';
|
} from '@/components/ui/field';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { transfer } from '@/routes/admin/manage/stock-retail';
|
import { transfer } from '@/routes/admin/manage/retail-stock';
|
||||||
|
|
||||||
|
interface VariantOption {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
stock: number;
|
||||||
|
retail_stock: number;
|
||||||
|
}
|
||||||
|
|
||||||
const open = defineModel<boolean>('open', { default: false });
|
const open = defineModel<boolean>('open', { default: false });
|
||||||
|
|
||||||
@ -28,8 +43,9 @@ const props = defineProps<{
|
|||||||
variantId: number;
|
variantId: number;
|
||||||
variantName: string;
|
variantName: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
stockBagus: number;
|
goodStock: number;
|
||||||
stockRetail: number;
|
retailStock: number;
|
||||||
|
variants?: VariantOption[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@ -39,6 +55,22 @@ const emit = defineEmits<{
|
|||||||
const processing = ref(false);
|
const processing = ref(false);
|
||||||
const errors = reactive<Record<string, string>>({});
|
const errors = reactive<Record<string, string>>({});
|
||||||
|
|
||||||
|
const selectedVariantId = ref(0);
|
||||||
|
|
||||||
|
const hasMultipleVariants = computed(() => (props.variants?.length ?? 0) > 1);
|
||||||
|
|
||||||
|
const activeVariant = computed(() => {
|
||||||
|
if (hasMultipleVariants.value && selectedVariantId.value > 0) {
|
||||||
|
return props.variants?.find((v) => v.id === selectedVariantId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayVariantName = computed(() => activeVariant.value?.name ?? props.variantName);
|
||||||
|
const displayGoodStock = computed(() => activeVariant.value?.stock ?? props.goodStock);
|
||||||
|
const displayRetailStock = computed(() => activeVariant.value?.retail_stock ?? props.retailStock);
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
quantity: '',
|
quantity: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
@ -48,14 +80,30 @@ function resetForm() {
|
|||||||
form.quantity = '';
|
form.quantity = '';
|
||||||
form.notes = '';
|
form.notes = '';
|
||||||
Object.keys(errors).forEach((key) => delete errors[key]);
|
Object.keys(errors).forEach((key) => delete errors[key]);
|
||||||
|
|
||||||
|
if (hasMultipleVariants.value) {
|
||||||
|
selectedVariantId.value = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(open, (isOpen) => {
|
watch(open, (isOpen) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
resetForm();
|
resetForm();
|
||||||
|
|
||||||
|
if (!hasMultipleVariants.value && props.variantId > 0) {
|
||||||
|
selectedVariantId.value = props.variantId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const submitVariantId = computed(() => {
|
||||||
|
if (hasMultipleVariants.value) {
|
||||||
|
return selectedVariantId.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.variantId;
|
||||||
|
});
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
Object.keys(errors).forEach((key) => delete errors[key]);
|
Object.keys(errors).forEach((key) => delete errors[key]);
|
||||||
processing.value = true;
|
processing.value = true;
|
||||||
@ -64,7 +112,7 @@ async function submit() {
|
|||||||
await apiFetch(transfer.url(), {
|
await apiFetch(transfer.url(), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
product_variant_id: props.variantId,
|
product_variant_id: submitVariantId.value,
|
||||||
quantity: Number(form.quantity),
|
quantity: Number(form.quantity),
|
||||||
notes: form.notes || null,
|
notes: form.notes || null,
|
||||||
}),
|
}),
|
||||||
@ -108,14 +156,35 @@ function formatNumber(value: number): string {
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="rounded-lg border p-3 space-y-1.5">
|
<div class="rounded-lg border p-3 space-y-1.5">
|
||||||
<p class="text-sm font-medium">{{ productName }}</p>
|
<p class="text-sm font-medium">{{ productName }}</p>
|
||||||
<p class="text-xs text-muted-foreground">Varian: {{ variantName }}</p>
|
|
||||||
|
<Field v-if="hasMultipleVariants">
|
||||||
|
<FieldLabel for="transfer-variant" required>Pilih Varian</FieldLabel>
|
||||||
|
<Select :model-value="selectedVariantId ? String(selectedVariantId) : undefined" @update:model-value="selectedVariantId = Number($event)">
|
||||||
|
<SelectTrigger id="transfer-variant" class="w-full">
|
||||||
|
<SelectValue placeholder="Pilih varian" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem
|
||||||
|
v-for="variant in variants"
|
||||||
|
:key="variant.id"
|
||||||
|
:value="String(variant.id)"
|
||||||
|
>
|
||||||
|
{{ variant.name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<p v-if="!hasMultipleVariants" class="text-xs text-muted-foreground">Varian: {{ displayVariantName }}</p>
|
||||||
<div class="flex justify-between text-xs pt-1">
|
<div class="flex justify-between text-xs pt-1">
|
||||||
<span class="text-muted-foreground">Stok Bagus</span>
|
<span class="text-muted-foreground">Stok Bagus</span>
|
||||||
<span class="font-medium tabular-nums">{{ formatNumber(stockBagus) }}</span>
|
<span class="font-medium tabular-nums">{{ formatNumber(displayGoodStock) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between text-xs">
|
<div class="flex justify-between text-xs">
|
||||||
<span class="text-muted-foreground">Stok Ecer</span>
|
<span class="text-muted-foreground">Stok Ecer</span>
|
||||||
<span class="font-medium tabular-nums text-blue-600">{{ formatNumber(stockRetail) }}</span>
|
<span class="font-medium tabular-nums text-blue-600">{{ formatNumber(displayRetailStock) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -131,7 +200,7 @@ function formatNumber(value: number): string {
|
|||||||
<NumberInput
|
<NumberInput
|
||||||
id="retail-quantity"
|
id="retail-quantity"
|
||||||
v-model="form.quantity"
|
v-model="form.quantity"
|
||||||
:max="stockBagus"
|
:max="displayGoodStock"
|
||||||
placeholder="Masukkan jumlah"
|
placeholder="Masukkan jumlah"
|
||||||
/>
|
/>
|
||||||
<FieldError
|
<FieldError
|
||||||
@ -161,7 +230,7 @@ function formatNumber(value: number): string {
|
|||||||
>
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" :disabled="processing || !form.quantity || Number(form.quantity) < 1">
|
<Button type="submit" :disabled="processing || !form.quantity || Number(form.quantity) < 1 || (hasMultipleVariants && !selectedVariantId)">
|
||||||
<Save class="size-4" />
|
<Save class="size-4" />
|
||||||
{{ processing ? 'Mengirim...' : 'Ajukan Transfer' }}
|
{{ processing ? 'Mengirim...' : 'Ajukan Transfer' }}
|
||||||
</Button>
|
</Button>
|
||||||
@ -110,7 +110,7 @@ const emit = defineEmits<{
|
|||||||
{{ variant.name }}
|
{{ variant.name }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
<span v-if="isCashierUser" class="tabular-nums">Ecer: {{ variant.stock_retail ?? 0 }}</span>
|
<span v-if="isCashierUser" class="tabular-nums">Ecer: {{ variant.retail_stock ?? 0 }}</span>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="tabular-nums">Bagus: {{ variant.stock }}</span>
|
<span class="tabular-nums">Bagus: {{ variant.stock }}</span>
|
||||||
<span class="mx-1">·</span>
|
<span class="mx-1">·</span>
|
||||||
|
|||||||
@ -80,7 +80,7 @@ export function useOrderPosCart(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stockQuality === StockQuality.RETAIL) {
|
if (stockQuality === StockQuality.RETAIL) {
|
||||||
return variant.stock_retail ?? 0;
|
return variant.retail_stock ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return variant.stock;
|
return variant.stock;
|
||||||
|
|||||||
@ -21,7 +21,7 @@ const initialData = computed(() => ({
|
|||||||
id: variant.id,
|
id: variant.id,
|
||||||
name: variant.name,
|
name: variant.name,
|
||||||
stock: variant.stock,
|
stock: variant.stock,
|
||||||
stock_retail: variant.stock_retail,
|
retail_stock: variant.retail_stock,
|
||||||
images: variant.images ?? [],
|
images: variant.images ?? [],
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -42,7 +42,7 @@ const {
|
|||||||
client_id: createClientId(),
|
client_id: createClientId(),
|
||||||
name: '',
|
name: '',
|
||||||
stock: '0',
|
stock: '0',
|
||||||
stock_retail: '0',
|
retail_stock: '0',
|
||||||
media: createMediaUploadState(),
|
media: createMediaUploadState(),
|
||||||
}),
|
}),
|
||||||
() => {
|
() => {
|
||||||
@ -51,7 +51,7 @@ const {
|
|||||||
client_id: createClientId(),
|
client_id: createClientId(),
|
||||||
name: '',
|
name: '',
|
||||||
stock: '0',
|
stock: '0',
|
||||||
stock_retail: '0',
|
retail_stock: '0',
|
||||||
media: createMediaUploadState(),
|
media: createMediaUploadState(),
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@ -61,7 +61,7 @@ const {
|
|||||||
id: variant.id,
|
id: variant.id,
|
||||||
name: variant.name ?? '',
|
name: variant.name ?? '',
|
||||||
stock: variant.stock != null ? String(variant.stock) : '0',
|
stock: variant.stock != null ? String(variant.stock) : '0',
|
||||||
stock_retail: variant.stock_retail != null ? String(variant.stock_retail) : '0',
|
retail_stock: variant.retail_stock != null ? String(variant.retail_stock) : '0',
|
||||||
media: createMediaUploadState(variant.images ?? []),
|
media: createMediaUploadState(variant.images ?? []),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@ -100,7 +100,7 @@ function buildFormData(): FormData {
|
|||||||
appendToFormData(formData, (formData, index, variant) => {
|
appendToFormData(formData, (formData, index, variant) => {
|
||||||
formData.append(`variants[${index}][name]`, variant.name.trim());
|
formData.append(`variants[${index}][name]`, variant.name.trim());
|
||||||
formData.append(`variants[${index}][stock]`, String(Number.parseInt(String(variant.stock), 10) || 0));
|
formData.append(`variants[${index}][stock]`, String(Number.parseInt(String(variant.stock), 10) || 0));
|
||||||
formData.append(`variants[${index}][stock_retail]`, String(Number.parseInt(String(variant.stock_retail), 10) || 0));
|
formData.append(`variants[${index}][retail_stock]`, String(Number.parseInt(String(variant.retail_stock), 10) || 0));
|
||||||
appendMediaToFormData(formData, `variants[${index}]`, variant.media);
|
appendMediaToFormData(formData, `variants[${index}]`, variant.media);
|
||||||
}, props.method);
|
}, props.method);
|
||||||
|
|
||||||
@ -153,7 +153,7 @@ function submit() {
|
|||||||
@remove="removeVariant(variant.client_id)"
|
@remove="removeVariant(variant.client_id)"
|
||||||
@update:name="setVariantField(variant.client_id, 'name', $event)"
|
@update:name="setVariantField(variant.client_id, 'name', $event)"
|
||||||
@update:stock="setVariantField(variant.client_id, 'stock', $event)"
|
@update:stock="setVariantField(variant.client_id, 'stock', $event)"
|
||||||
@update:stock-retail="setVariantField(variant.client_id, 'stock_retail', $event)"
|
@update:retail-stock="setVariantField(variant.client_id, 'retail_stock', $event)"
|
||||||
@update:media="setVariantField(variant.client_id, 'media', $event)"
|
@update:media="setVariantField(variant.client_id, 'media', $event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,7 @@ const emit = defineEmits<{
|
|||||||
remove: [];
|
remove: [];
|
||||||
'update:name': [value: string];
|
'update:name': [value: string];
|
||||||
'update:stock': [value: string];
|
'update:stock': [value: string];
|
||||||
'update:stock-retail': [value: string];
|
'update:retail-stock': [value: string];
|
||||||
'update:media': [value: MediaUploadState];
|
'update:media': [value: MediaUploadState];
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
@ -79,15 +79,15 @@ const emit = defineEmits<{
|
|||||||
<FieldError :errors="variantErrors(variant.client_id, 'stock')" />
|
<FieldError :errors="variantErrors(variant.client_id, 'stock')" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel :for="`variant_stock_retail_${variant.client_id}`" required>
|
<FieldLabel :for="`variant_retail_stock_${variant.client_id}`" required>
|
||||||
Stok Ecer
|
Stok Ecer
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
:id="`variant_stock_retail_${variant.client_id}`"
|
:id="`variant_retail_stock_${variant.client_id}`"
|
||||||
:model-value="variant.stock_retail"
|
:model-value="variant.retail_stock"
|
||||||
@update:model-value="emit('update:stock-retail', String($event))"
|
@update:model-value="emit('update:retail-stock', String($event))"
|
||||||
/>
|
/>
|
||||||
<FieldError :errors="variantErrors(variant.client_id, 'stock_retail')" />
|
<FieldError :errors="variantErrors(variant.client_id, 'retail_stock')" />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ArrowLeftRight } from '@lucide/vue';
|
import { computed } from 'vue';
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { DataTableEmpty } from '@/components/data-table';
|
import { DataTableEmpty } from '@/components/data-table';
|
||||||
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
|
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
|
||||||
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
|
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
|
||||||
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||||
import StockRetailTransferModal from '@/components/modal/StockRetailTransferModal.vue';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -56,27 +53,6 @@ const paginationSummary = usePaginationSummary(() => props.pagination, showingCo
|
|||||||
function rowNumber(index: number): number {
|
function rowNumber(index: number): number {
|
||||||
return groupedTableRowNumber(props.firstItem, index);
|
return groupedTableRowNumber(props.firstItem, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stock Retail Transfer Modal
|
|
||||||
const transferModalOpen = ref(false);
|
|
||||||
const transferVariantId = ref(0);
|
|
||||||
const transferVariantName = ref('');
|
|
||||||
const transferProductName = ref('');
|
|
||||||
const transferStockBagus = ref(0);
|
|
||||||
const transferStockRetail = ref(0);
|
|
||||||
|
|
||||||
function openTransferModal(product: ProductListItem, variant: Variant) {
|
|
||||||
transferVariantId.value = variant.id;
|
|
||||||
transferVariantName.value = variant.name;
|
|
||||||
transferProductName.value = product.name;
|
|
||||||
transferStockBagus.value = variant.stock;
|
|
||||||
transferStockRetail.value = variant.stock_retail;
|
|
||||||
transferModalOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTransferSubmitted() {
|
|
||||||
// Stock won't change until owner approves, no need to update local data
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -141,12 +117,11 @@ function onTransferSubmitted() {
|
|||||||
<TableHead>Stok Reject</TableHead>
|
<TableHead>Stok Reject</TableHead>
|
||||||
<TableHead>Stok Ecer</TableHead>
|
<TableHead>Stok Ecer</TableHead>
|
||||||
<TableHead>Harga</TableHead>
|
<TableHead>Harga</TableHead>
|
||||||
<TableHead v-if="can('stocks.view')" class="w-[60px]">Aksi</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-if="!product.variants.length" :key="`${product.id}-empty`">
|
<TableRow v-if="!product.variants.length" :key="`${product.id}-empty`">
|
||||||
<TableCell :colspan="can('stocks.view') ? 7 : 6" class="text-muted-foreground">
|
<TableCell :colspan="6" class="text-muted-foreground">
|
||||||
Belum ada varian
|
Belum ada varian
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -164,7 +139,7 @@ function onTransferSubmitted() {
|
|||||||
{{ variant.reject_stock_formatted }}
|
{{ variant.reject_stock_formatted }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums text-blue-600">
|
<TableCell class="tabular-nums text-blue-600">
|
||||||
{{ variant.stock_retail_formatted }}
|
{{ variant.retail_stock_formatted }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div v-if="variant.prices?.length" class="space-y-0.5 text-xs">
|
<div v-if="variant.prices?.length" class="space-y-0.5 text-xs">
|
||||||
@ -184,17 +159,6 @@ function onTransferSubmitted() {
|
|||||||
</div>
|
</div>
|
||||||
<span v-else class="text-xs text-muted-foreground">Belum ada harga</span>
|
<span v-else class="text-xs text-muted-foreground">Belum ada harga</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell v-if="can('stocks.view')">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon-sm"
|
|
||||||
title="Transfer ke Stok Ecer"
|
|
||||||
:disabled="product.has_pending_request"
|
|
||||||
@click="openTransferModal(product, variant)"
|
|
||||||
>
|
|
||||||
<ArrowLeftRight class="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
@ -209,14 +173,4 @@ function onTransferSubmitted() {
|
|||||||
:pagination-links="paginationLinks"
|
:pagination-links="paginationLinks"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StockRetailTransferModal
|
|
||||||
v-model:open="transferModalOpen"
|
|
||||||
:variant-id="transferVariantId"
|
|
||||||
:variant-name="transferVariantName"
|
|
||||||
:product-name="transferProductName"
|
|
||||||
:stock-bagus="transferStockBagus"
|
|
||||||
:stock-retail="transferStockRetail"
|
|
||||||
@submitted="onTransferSubmitted"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RowDeleteAction, RowEditAction } from '@/components/button';
|
import { ref } from 'vue';
|
||||||
|
import { RowDeleteAction, RowEditAction, RowTransferAction } from '@/components/button';
|
||||||
|
import RetailStockTransferModal from '@/components/modal/RetailStockTransferModal.vue';
|
||||||
import OwnerVerificationRowActions from '@/components/owner-verification/OwnerVerificationRowActions.vue';
|
import OwnerVerificationRowActions from '@/components/owner-verification/OwnerVerificationRowActions.vue';
|
||||||
import { useCan } from '@/composables/useCan';
|
import { useCan } from '@/composables/useCan';
|
||||||
import { edit, destroy } from '@/routes/admin/master/products';
|
import { edit, destroy } from '@/routes/admin/master/products';
|
||||||
@ -10,6 +12,35 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { can } = useCan();
|
const { can } = useCan();
|
||||||
|
|
||||||
|
const transferModalOpen = ref(false);
|
||||||
|
const transferVariantId = ref(0);
|
||||||
|
const transferVariantName = ref('');
|
||||||
|
const transferGoodStock = ref(0);
|
||||||
|
const transferRetailStock = ref(0);
|
||||||
|
|
||||||
|
function openTransferModal(variantId: number, variantName: string, goodStock: number, retailStock: number) {
|
||||||
|
transferVariantId.value = variantId;
|
||||||
|
transferVariantName.value = variantName;
|
||||||
|
transferGoodStock.value = goodStock;
|
||||||
|
transferRetailStock.value = retailStock;
|
||||||
|
transferModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTransferClick() {
|
||||||
|
const variants = props.product.variants ?? [];
|
||||||
|
|
||||||
|
if (variants.length === 1) {
|
||||||
|
const v = variants[0];
|
||||||
|
openTransferModal(v.id, v.name, v.stock, v.retail_stock);
|
||||||
|
} else if (variants.length > 1) {
|
||||||
|
transferVariantId.value = 0;
|
||||||
|
transferVariantName.value = '';
|
||||||
|
transferGoodStock.value = 0;
|
||||||
|
transferRetailStock.value = 0;
|
||||||
|
transferModalOpen.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -18,6 +49,12 @@ const { can } = useCan();
|
|||||||
:id="product.pending_request_id" :title="product.name" subject-label="Produk"
|
:id="product.pending_request_id" :title="product.name" subject-label="Produk"
|
||||||
:action-label="product.pending_request_action_label" />
|
:action-label="product.pending_request_action_label" />
|
||||||
|
|
||||||
|
<RowTransferAction
|
||||||
|
v-if="can('stocks.view')"
|
||||||
|
:disabled="product.has_pending_request"
|
||||||
|
:tooltip="product.has_pending_request ? 'Menunggu verifikasi owner' : 'Transfer Stok Ecer'"
|
||||||
|
@click="handleTransferClick"
|
||||||
|
/>
|
||||||
<RowEditAction v-if="can('products.update')" :href="edit.url(product.id)"
|
<RowEditAction v-if="can('products.update')" :href="edit.url(product.id)"
|
||||||
:disabled="product.has_pending_request"
|
:disabled="product.has_pending_request"
|
||||||
:tooltip="product.has_pending_request ? 'Menunggu verifikasi owner' : 'Ubah'" />
|
:tooltip="product.has_pending_request ? 'Menunggu verifikasi owner' : 'Ubah'" />
|
||||||
@ -27,4 +64,15 @@ const { can } = useCan();
|
|||||||
:description="`Pengajuan hapus produk ${product.name} akan dikirim ke owner untuk verifikasi.`"
|
:description="`Pengajuan hapus produk ${product.name} akan dikirim ke owner untuk verifikasi.`"
|
||||||
error-message="Gagal mengajukan penghapusan produk." />
|
error-message="Gagal mengajukan penghapusan produk." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RetailStockTransferModal
|
||||||
|
v-model:open="transferModalOpen"
|
||||||
|
:variant-id="transferVariantId"
|
||||||
|
:variant-name="transferVariantName"
|
||||||
|
:product-name="product.name"
|
||||||
|
:good-stock="transferGoodStock"
|
||||||
|
:retail-stock="transferRetailStock"
|
||||||
|
:variants="(product.variants ?? []).map(v => ({ id: v.id, name: v.name, stock: v.stock, retail_stock: v.retail_stock }))"
|
||||||
|
@submitted="() => {}"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -43,8 +43,8 @@ export interface Variant {
|
|||||||
stock_formatted: string;
|
stock_formatted: string;
|
||||||
reject_stock: number;
|
reject_stock: number;
|
||||||
reject_stock_formatted: string;
|
reject_stock_formatted: string;
|
||||||
stock_retail: number;
|
retail_stock: number;
|
||||||
stock_retail_formatted: string;
|
retail_stock_formatted: string;
|
||||||
prices: Price[];
|
prices: Price[];
|
||||||
images: MediaItem[];
|
images: MediaItem[];
|
||||||
}
|
}
|
||||||
@ -91,7 +91,7 @@ export interface ProductVariantFormItem {
|
|||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
stock: string | number;
|
stock: string | number;
|
||||||
stock_retail: string | number;
|
retail_stock: string | number;
|
||||||
media: MediaUploadState;
|
media: MediaUploadState;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@ -104,7 +104,7 @@ export type ProductFormInitialData = {
|
|||||||
id?: number;
|
id?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
stock?: number | string;
|
stock?: number | string;
|
||||||
stock_retail?: number | string;
|
retail_stock?: number | string;
|
||||||
images?: MediaItem[];
|
images?: MediaItem[];
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -21,8 +21,8 @@
|
|||||||
use App\Http\Controllers\Admin\Manage\OwnerVerificationController;
|
use App\Http\Controllers\Admin\Manage\OwnerVerificationController;
|
||||||
use App\Http\Controllers\Admin\Manage\Purchase\PurchaseController;
|
use App\Http\Controllers\Admin\Manage\Purchase\PurchaseController;
|
||||||
use App\Http\Controllers\Admin\Manage\Purchase\PurchaseDraftItemController;
|
use App\Http\Controllers\Admin\Manage\Purchase\PurchaseDraftItemController;
|
||||||
|
use App\Http\Controllers\Admin\Manage\Stock\RetailStockController;
|
||||||
use App\Http\Controllers\Admin\Manage\Stock\StockController;
|
use App\Http\Controllers\Admin\Manage\Stock\StockController;
|
||||||
use App\Http\Controllers\Admin\Manage\Stock\StockRetailController;
|
|
||||||
use App\Http\Controllers\Admin\Manage\StokOpnameController;
|
use App\Http\Controllers\Admin\Manage\StokOpnameController;
|
||||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||||
use App\Http\Controllers\Admin\Master\CustomerController;
|
use App\Http\Controllers\Admin\Master\CustomerController;
|
||||||
@ -357,10 +357,10 @@
|
|||||||
->name('verify');
|
->name('verify');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::prefix('stock-retail')->name('stock-retail.')
|
Route::prefix('retail-stock')->name('retail-stock.')
|
||||||
->middleware('permission:'.Permission::STOCKS_VIEW->value)
|
->middleware('permission:'.Permission::STOCKS_VIEW->value)
|
||||||
->group(function () {
|
->group(function () {
|
||||||
Route::post('/transfer', [StockRetailController::class, 'transfer'])->name('transfer');
|
Route::post('/transfer', [RetailStockController::class, 'transfer'])->name('transfer');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::prefix('stok-opnames')->name('stok-opnames.')
|
Route::prefix('stok-opnames')->name('stok-opnames.')
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user