feat: add stock ecer transfer functionality with new controller, service, and frontend modal integration
This commit is contained in:
parent
414ce51904
commit
8146da86c6
@ -13,6 +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_ECER_TRANSFER = 'stock_ecer_transfer';
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
@ -22,6 +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_ECER_TRANSFER => 'Transfer Stok Ecer',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,12 +10,14 @@ enum ProductStockQuality: string
|
|||||||
|
|
||||||
case GOOD = 'good';
|
case GOOD = 'good';
|
||||||
case REJECT = 'reject';
|
case REJECT = 'reject';
|
||||||
|
case ECER = 'ecer';
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::GOOD => 'Bagus',
|
self::GOOD => 'Bagus',
|
||||||
self::REJECT => 'Reject',
|
self::REJECT => 'Reject',
|
||||||
|
self::ECER => 'Eceran',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
app/Http/Controllers/Admin/Manage/StockEcerController.php
Normal file
32
app/Http/Controllers/Admin/Manage/StockEcerController.php
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Manage;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Admin\Manage\StockEcerTransferRequest;
|
||||||
|
use App\Services\Manage\StockEcerService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class StockEcerController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly StockEcerService $stockEcerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function transfer(StockEcerTransferRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
$this->stockEcerService->transfer(
|
||||||
|
$validated['product_variant_id'],
|
||||||
|
$validated['quantity'],
|
||||||
|
$request->user(),
|
||||||
|
$validated['notes'] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Pengajuan transfer stok ecer berhasil dikirim dan menunggu verifikasi owner.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -84,7 +84,10 @@ public function attributes(): array
|
|||||||
public function withValidator(Validator $validator): void
|
public function withValidator(Validator $validator): void
|
||||||
{
|
{
|
||||||
$validator->after(function (Validator $validator): void {
|
$validator->after(function (Validator $validator): void {
|
||||||
if ($this->filled('marketing_id') && $this->marketing_id !== 'none' && ! $this->filled('customer_id')) {
|
// Skip customer validation for cashier role
|
||||||
|
$isCashier = $this->user()?->hasRole('cashier') ?? false;
|
||||||
|
|
||||||
|
if (! $isCashier && $this->filled('marketing_id') && $this->marketing_id !== 'none' && ! $this->filled('customer_id')) {
|
||||||
$validator->errors()->add('customer_id', 'Pelanggan wajib diisi jika marketing dipilih.');
|
$validator->errors()->add('customer_id', 'Pelanggan wajib diisi jika marketing dipilih.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
43
app/Http/Requests/Admin/Manage/StockEcerTransferRequest.php
Normal file
43
app/Http/Requests/Admin/Manage/StockEcerTransferRequest.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin\Manage;
|
||||||
|
|
||||||
|
use App\Enums\Permission;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StockEcerTransferRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can(Permission::STOCKS_VIEW->value) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'product_variant_id' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('product_variants', 'id')->whereNull('deleted_at'),
|
||||||
|
],
|
||||||
|
'quantity' => ['required', 'integer', 'min:1'],
|
||||||
|
'notes' => ['nullable', 'string', 'max:500'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function attributes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'product_variant_id' => 'varian produk',
|
||||||
|
'quantity' => 'jumlah',
|
||||||
|
'notes' => 'catatan',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -42,6 +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_ecer' => ['required', 'integer', 'min:0'],
|
||||||
...$this->variantImageRules(),
|
...$this->variantImageRules(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -59,6 +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_ecer' => 'stok ecer',
|
||||||
...$this->variantImageAttributes('variants', 'foto varian'),
|
...$this->variantImageAttributes('variants', 'foto varian'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ protected function casts(): array
|
|||||||
return [
|
return [
|
||||||
'stock' => 'integer',
|
'stock' => 'integer',
|
||||||
'reject_stock' => 'integer',
|
'reject_stock' => 'integer',
|
||||||
|
'stock_ecer' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
37
app/Models/StockEcerHistory.php
Normal file
37
app/Models/StockEcerHistory.php
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Guarded(['id'])]
|
||||||
|
class StockEcerHistory extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'stock_ecer_histories';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'quantity' => 'integer',
|
||||||
|
'stock_before' => 'integer',
|
||||||
|
'stock_ecer_before' => 'integer',
|
||||||
|
'stock_after' => 'integer',
|
||||||
|
'stock_ecer_after' => 'integer',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function productVariant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProductVariant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -72,6 +72,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::ECER => 'stock_ecer',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -314,8 +315,15 @@ public function draftItemsForUser(User $user): array
|
|||||||
*/
|
*/
|
||||||
public function syncDraftItem(array $validated, User $user): array
|
public function syncDraftItem(array $validated, User $user): array
|
||||||
{
|
{
|
||||||
$priceType = PriceType::from($validated['price_type']);
|
// Force ecer price type and stock quality for cashier role
|
||||||
$stockQuality = ProductStockQuality::from($validated['stock_quality']);
|
$priceType = $user->hasRole('cashier')
|
||||||
|
? PriceType::ECER
|
||||||
|
: PriceType::from($validated['price_type']);
|
||||||
|
|
||||||
|
$stockQuality = $user->hasRole('cashier')
|
||||||
|
? ProductStockQuality::ECER
|
||||||
|
: ProductStockQuality::from($validated['stock_quality']);
|
||||||
|
|
||||||
$variant = ProductVariant::query()->findOrFail($validated['product_variant_id']);
|
$variant = ProductVariant::query()->findOrFail($validated['product_variant_id']);
|
||||||
$unitPrice = $this->resolveUnitPrice($variant->id, $priceType);
|
$unitPrice = $this->resolveUnitPrice($variant->id, $priceType);
|
||||||
|
|
||||||
@ -367,7 +375,10 @@ public function removeDraftItem(User $user, ProductVariant $productVariant, Prod
|
|||||||
*/
|
*/
|
||||||
public function resyncDraftPrices(User $user, string $priceTypeValue): array
|
public function resyncDraftPrices(User $user, string $priceTypeValue): array
|
||||||
{
|
{
|
||||||
$priceType = PriceType::from($priceTypeValue);
|
// Force ecer price type for cashier role
|
||||||
|
$priceType = $user->hasRole('cashier')
|
||||||
|
? PriceType::ECER
|
||||||
|
: PriceType::from($priceTypeValue);
|
||||||
|
|
||||||
$items = $this->draftItemsQuery($user)
|
$items = $this->draftItemsQuery($user)
|
||||||
->with([
|
->with([
|
||||||
@ -400,6 +411,14 @@ public function resyncDraftPrices(User $user, string $priceTypeValue): array
|
|||||||
public function create(array $validated, User $user): Order
|
public function create(array $validated, User $user): Order
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
// Force cashier settings
|
||||||
|
if ($user->hasRole('cashier')) {
|
||||||
|
$validated['channel'] = 'store';
|
||||||
|
$validated['price_type'] = 'ecer';
|
||||||
|
$validated['payment_type'] = 'cash';
|
||||||
|
unset($validated['customer_id']);
|
||||||
|
}
|
||||||
|
|
||||||
$order = DB::transaction(function () use ($validated, $user): Order {
|
$order = DB::transaction(function () use ($validated, $user): Order {
|
||||||
$priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']);
|
$priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']);
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Models\Cutting;
|
use App\Models\Cutting;
|
||||||
use App\Models\OwnerVerificationRequest;
|
use App\Models\OwnerVerificationRequest;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
|
use App\Models\ProductVariant;
|
||||||
use App\Models\Purchase;
|
use App\Models\Purchase;
|
||||||
use App\Models\RawMaterial;
|
use App\Models\RawMaterial;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -35,6 +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 StockEcerService $stockEcerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -282,6 +284,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->stockEcerService->rejectStockEcerTransfer($request),
|
||||||
default => throw ValidationException::withMessages([
|
default => throw ValidationException::withMessages([
|
||||||
'subject_type' => 'Tipe data verifikasi tidak didukung.',
|
'subject_type' => 'Tipe data verifikasi tidak didukung.',
|
||||||
]),
|
]),
|
||||||
@ -295,6 +298,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->stockEcerService->applyStockEcerTransfer($request),
|
||||||
default => throw ValidationException::withMessages([
|
default => throw ValidationException::withMessages([
|
||||||
'subject_type' => 'Tipe data verifikasi tidak didukung.',
|
'subject_type' => 'Tipe data verifikasi tidak didukung.',
|
||||||
]),
|
]),
|
||||||
@ -408,7 +412,7 @@ private function buildVerificationRequestQuery(
|
|||||||
$query->where('username', 'like', "%{$search}%")
|
$query->where('username', 'like', "%{$search}%")
|
||||||
->orWhereHas('profile', fn (Builder $query) => $query->where('full_name', 'like', "%{$search}%"));
|
->orWhereHas('profile', fn (Builder $query) => $query->where('full_name', 'like', "%{$search}%"));
|
||||||
})
|
})
|
||||||
->orWhereHasMorph('subject', [Product::class, RawMaterial::class, Purchase::class], function (Builder $query, string $type) use ($search): void {
|
->orWhereHasMorph('subject', [Product::class, RawMaterial::class, Purchase::class, ProductVariant::class], function (Builder $query, string $type) use ($search): void {
|
||||||
if ($type === Purchase::class) {
|
if ($type === Purchase::class) {
|
||||||
$query->where('notes', 'like', "%{$search}%")
|
$query->where('notes', 'like', "%{$search}%")
|
||||||
->orWhereHas('supplier', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
|
->orWhereHas('supplier', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
|
||||||
@ -488,6 +492,13 @@ private function requestTitle(OwnerVerificationRequest $request): string
|
|||||||
return $request->subject->supplier?->name ?? 'Belanja';
|
return $request->subject->supplier?->name ?? 'Belanja';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($request->subject instanceof ProductVariant) {
|
||||||
|
$payload = is_array($request->payload) ? $request->payload : [];
|
||||||
|
$newData = $payload['new'] ?? [];
|
||||||
|
|
||||||
|
return ($newData['product_name'] ?? 'Produk') . ' - ' . ($newData['variant_name'] ?? $request->subject->name);
|
||||||
|
}
|
||||||
|
|
||||||
$payload = is_array($request->payload) ? $request->payload : [];
|
$payload = is_array($request->payload) ? $request->payload : [];
|
||||||
|
|
||||||
foreach (['new', 'old'] as $key) {
|
foreach (['new', 'old'] as $key) {
|
||||||
@ -523,7 +534,7 @@ private function notifyRequestSubmitter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
$url = match ($request->subject_type) {
|
$url = match ($request->subject_type) {
|
||||||
Product::class => route('admin.master.products.index'),
|
Product::class, ProductVariant::class => route('admin.master.products.index'),
|
||||||
RawMaterial::class => route('admin.master.raw_materials.index'),
|
RawMaterial::class => route('admin.master.raw_materials.index'),
|
||||||
Purchase::class => route('admin.manage.purchases.index'),
|
Purchase::class => route('admin.manage.purchases.index'),
|
||||||
MarketplaceSettings::class => route('admin.system.settings.index'),
|
MarketplaceSettings::class => route('admin.system.settings.index'),
|
||||||
|
|||||||
151
app/Services/Manage/StockEcerService.php
Normal file
151
app/Services/Manage/StockEcerService.php
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Manage;
|
||||||
|
|
||||||
|
use App\Enums\OwnerVerificationAction;
|
||||||
|
use App\Enums\OwnerVerificationStatus;
|
||||||
|
use App\Models\OwnerVerificationRequest;
|
||||||
|
use App\Models\ProductVariant;
|
||||||
|
use App\Models\StockEcerHistory;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\System\PushNotificationService;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class StockEcerService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PushNotificationService $pushNotificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a stock ecer transfer request for owner verification.
|
||||||
|
*/
|
||||||
|
public function transfer(int $variantId, int $quantity, User $user, ?string $notes = null): OwnerVerificationRequest
|
||||||
|
{
|
||||||
|
if ($quantity <= 0) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'quantity' => 'Jumlah harus lebih dari 0.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant = ProductVariant::query()->findOrFail($variantId);
|
||||||
|
|
||||||
|
if ($variant->stock < $quantity) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'quantity' => "Stok bagus tidak mencukupi. Stok saat ini: {$variant->stock} pcs.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingPending = OwnerVerificationRequest::query()
|
||||||
|
->where('subject_type', ProductVariant::class)
|
||||||
|
->where('subject_id', $variant->id)
|
||||||
|
->where('action', OwnerVerificationAction::STOCK_ECER_TRANSFER)
|
||||||
|
->where('status', OwnerVerificationStatus::PENDING)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($existingPending) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'quantity' => 'Masih ada pengajuan transfer stok ecer yang menunggu verifikasi untuk varian ini.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$request = OwnerVerificationRequest::create([
|
||||||
|
'action' => OwnerVerificationAction::STOCK_ECER_TRANSFER,
|
||||||
|
'status' => OwnerVerificationStatus::PENDING,
|
||||||
|
'subject_type' => ProductVariant::class,
|
||||||
|
'subject_id' => $variant->id,
|
||||||
|
'submitted_by_id' => $user->id,
|
||||||
|
'payload' => [
|
||||||
|
'old' => [
|
||||||
|
'stock' => $variant->stock,
|
||||||
|
'stock_ecer' => $variant->stock_ecer,
|
||||||
|
],
|
||||||
|
'new' => [
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'stock' => $variant->stock - $quantity,
|
||||||
|
'stock_ecer' => $variant->stock_ecer + $quantity,
|
||||||
|
'notes' => $notes,
|
||||||
|
'variant_name' => $variant->name,
|
||||||
|
'product_name' => $variant->product?->name ?? '-',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->pushNotificationService->sendToRoles(
|
||||||
|
'📦 Transfer Stok Ecer Menunggu Persetujuan Owner',
|
||||||
|
"Pengajuan transfer {$quantity} pcs stok ecer untuk varian '{$variant->name}' menunggu verifikasi owner.",
|
||||||
|
['owner', 'developer'],
|
||||||
|
route('admin.master.products.index'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->pushNotificationService->sendToUser(
|
||||||
|
'📤 Pengajuan Transfer Terkirim',
|
||||||
|
"Pengajuan transfer {$quantity} pcs stok ecer untuk varian '{$variant->name}' telah dikirim dan menunggu verifikasi owner.",
|
||||||
|
$user->id,
|
||||||
|
route('admin.master.products.index'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $request;
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
throw $e;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Gagal mengajukan transfer stok ecer: '.$e->getMessage(), [
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the stock ecer transfer when owner approves.
|
||||||
|
*/
|
||||||
|
public function applyStockEcerTransfer(OwnerVerificationRequest $request): void
|
||||||
|
{
|
||||||
|
$payload = $request->payload ?? [];
|
||||||
|
$newData = $payload['new'] ?? [];
|
||||||
|
$quantity = (int) ($newData['quantity'] ?? 0);
|
||||||
|
|
||||||
|
if ($quantity <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($request, $quantity, $newData): void {
|
||||||
|
$variant = ProductVariant::query()
|
||||||
|
->whereKey($request->subject_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$stockBefore = $variant->stock;
|
||||||
|
$stockEcerBefore = $variant->stock_ecer;
|
||||||
|
|
||||||
|
$variant->decrement('stock', $quantity);
|
||||||
|
$variant->increment('stock_ecer', $quantity);
|
||||||
|
|
||||||
|
StockEcerHistory::create([
|
||||||
|
'product_variant_id' => $variant->id,
|
||||||
|
'user_id' => $request->submitted_by_id,
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'stock_before' => $stockBefore,
|
||||||
|
'stock_ecer_before' => $stockEcerBefore,
|
||||||
|
'stock_after' => $stockBefore - $quantity,
|
||||||
|
'stock_ecer_after' => $stockEcerBefore + $quantity,
|
||||||
|
'notes' => $newData['notes'] ?? null,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject the stock ecer transfer (no-op since nothing changed).
|
||||||
|
*/
|
||||||
|
public function rejectStockEcerTransfer(OwnerVerificationRequest $request): void
|
||||||
|
{
|
||||||
|
// No-op: nothing was changed yet, so nothing to rollback.
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -76,6 +76,17 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
|
|||||||
|
|
||||||
$pendingRequest = $product->pendingOwnerVerificationRequest;
|
$pendingRequest = $product->pendingOwnerVerificationRequest;
|
||||||
|
|
||||||
|
// Also check for pending stock ecer transfer on variants
|
||||||
|
if ($pendingRequest === null) {
|
||||||
|
$pendingRequest = OwnerVerificationRequest::query()
|
||||||
|
->where('subject_type', ProductVariant::class)
|
||||||
|
->whereIn('subject_id', $product->variants->pluck('id'))
|
||||||
|
->where('action', OwnerVerificationAction::STOCK_ECER_TRANSFER)
|
||||||
|
->pending()
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
$product->setAttribute('has_pending_request', $pendingRequest !== null);
|
$product->setAttribute('has_pending_request', $pendingRequest !== null);
|
||||||
$product->setAttribute('pending_request_id', $pendingRequest?->id);
|
$product->setAttribute('pending_request_id', $pendingRequest?->id);
|
||||||
$product->setAttribute('pending_request_action', $pendingRequest?->action->value);
|
$product->setAttribute('pending_request_action', $pendingRequest?->action->value);
|
||||||
@ -108,6 +119,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_ecer' => $variantData['stock_ecer'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->syncVariantImages($variant, $variantData, $index);
|
$this->syncVariantImages($variant, $variantData, $index);
|
||||||
@ -369,6 +381,7 @@ private function applyPayloadToProduct(
|
|||||||
$variant->update([
|
$variant->update([
|
||||||
'name' => $variantData['name'],
|
'name' => $variantData['name'],
|
||||||
'stock' => $variantData['stock'],
|
'stock' => $variantData['stock'],
|
||||||
|
'stock_ecer' => $variantData['stock_ecer'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($verificationRequest !== null) {
|
if ($verificationRequest !== null) {
|
||||||
@ -381,6 +394,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_ecer' => $variantData['stock_ecer'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($verificationRequest !== null) {
|
if ($verificationRequest !== null) {
|
||||||
@ -525,6 +539,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_ecer' => $variant->stock_ecer,
|
||||||
])
|
])
|
||||||
->all(),
|
->all(),
|
||||||
]);
|
]);
|
||||||
@ -560,6 +575,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_ecer' => $variantData['stock_ecer'],
|
||||||
'remove_media_ids' => $variantData['remove_media_ids'] ?? [],
|
'remove_media_ids' => $variantData['remove_media_ids'] ?? [],
|
||||||
])
|
])
|
||||||
->all(),
|
->all(),
|
||||||
|
|||||||
@ -498,6 +498,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_ecer) as total_ecer,
|
||||||
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
|
||||||
')
|
')
|
||||||
@ -515,6 +516,7 @@ public function getProductStock(): array
|
|||||||
return [
|
return [
|
||||||
'total_stock' => (int) ($variants->total_stock ?? 0),
|
'total_stock' => (int) ($variants->total_stock ?? 0),
|
||||||
'total_reject' => (int) ($variants->total_reject ?? 0),
|
'total_reject' => (int) ($variants->total_reject ?? 0),
|
||||||
|
'total_ecer' => (int) ($variants->total_ecer ?? 0),
|
||||||
'total_value' => (int) ($totalValue ?? 0),
|
'total_value' => (int) ($totalValue ?? 0),
|
||||||
'total_products' => (int) ($variants->total_products ?? 0),
|
'total_products' => (int) ($variants->total_products ?? 0),
|
||||||
'total_variants' => (int) ($variants->total_variants ?? 0),
|
'total_variants' => (int) ($variants->total_variants ?? 0),
|
||||||
|
|||||||
@ -10,6 +10,8 @@ class VerificationChangeFormatter
|
|||||||
private const HIDDEN_FIELDS = [
|
private const HIDDEN_FIELDS = [
|
||||||
'category_ids',
|
'category_ids',
|
||||||
'unit',
|
'unit',
|
||||||
|
'variant_name',
|
||||||
|
'product_name',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -73,6 +75,11 @@ private static function label(string $field): string
|
|||||||
'is_active' => 'Status Aktif',
|
'is_active' => 'Status Aktif',
|
||||||
'variants' => 'Varian',
|
'variants' => 'Varian',
|
||||||
'prices' => 'Varian Harga',
|
'prices' => 'Varian Harga',
|
||||||
|
'quantity' => 'Jumlah Transfer',
|
||||||
|
'stock' => 'Stok Bagus',
|
||||||
|
'stock_ecer' => 'Stok Ecer',
|
||||||
|
'variant_name' => 'Varian',
|
||||||
|
'product_name' => 'Produk',
|
||||||
'tiktok_shop_platform_commission' => 'TikTok Shop - Komisi Platform',
|
'tiktok_shop_platform_commission' => 'TikTok Shop - Komisi Platform',
|
||||||
'tiktok_shop_logistics_service_fee' => 'TikTok Shop - Biaya Logistik',
|
'tiktok_shop_logistics_service_fee' => 'TikTok Shop - Biaya Logistik',
|
||||||
'tiktok_shop_dynamic_commission' => 'TikTok Shop - Komisi Dinamis',
|
'tiktok_shop_dynamic_commission' => 'TikTok Shop - Komisi Dinamis',
|
||||||
@ -179,6 +186,10 @@ private static function presentValue(string $field, mixed $value): mixed
|
|||||||
return implode(', ', $value);
|
return implode(', ', $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (in_array($field, ['quantity', 'stock', 'stock_ecer'], true)) {
|
||||||
|
return (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('product_variants', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('stock_ecer')->default(0)->after('reject_stock');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('product_variants', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('stock_ecer');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('stock_ecer_histories', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('product_variant_id')->constrained('product_variants')->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->unsignedInteger('quantity');
|
||||||
|
$table->unsignedInteger('stock_before');
|
||||||
|
$table->unsignedInteger('stock_ecer_before');
|
||||||
|
$table->unsignedInteger('stock_after');
|
||||||
|
$table->unsignedInteger('stock_ecer_after');
|
||||||
|
$table->string('notes')->nullable();
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('stock_ecer_histories');
|
||||||
|
}
|
||||||
|
};
|
||||||
177
resources/js/components/modal/StockEcerTransferModal.vue
Normal file
177
resources/js/components/modal/StockEcerTransferModal.vue
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ArrowLeftRight, Save } from '@lucide/vue';
|
||||||
|
import { reactive, ref, watch } from 'vue';
|
||||||
|
import { toast } from 'vue-sonner';
|
||||||
|
import { NumberInput } from '@/components/form/number-input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldSet,
|
||||||
|
} from '@/components/ui/field';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import { transfer } from '@/routes/admin/manage/stock-ecer';
|
||||||
|
|
||||||
|
const open = defineModel<boolean>('open', { default: false });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
variantId: number;
|
||||||
|
variantName: string;
|
||||||
|
productName: string;
|
||||||
|
stockBagus: number;
|
||||||
|
stockEcer: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submitted: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const processing = ref(false);
|
||||||
|
const errors = reactive<Record<string, string>>({});
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
quantity: '',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.quantity = '';
|
||||||
|
form.notes = '';
|
||||||
|
Object.keys(errors).forEach((key) => delete errors[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(open, (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
Object.keys(errors).forEach((key) => delete errors[key]);
|
||||||
|
processing.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiFetch(transfer.url(), {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
product_variant_id: props.variantId,
|
||||||
|
quantity: Number(form.quantity),
|
||||||
|
notes: form.notes || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
emit('submitted');
|
||||||
|
open.value = false;
|
||||||
|
toast.success('Pengajuan transfer stok ecer berhasil dikirim. Menunggu verifikasi owner.');
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Gagal menyimpan data.';
|
||||||
|
|
||||||
|
if (message.includes('jumlah') || message.includes('Jumlah')) {
|
||||||
|
errors.quantity = message;
|
||||||
|
} else if (message.includes('Stok')) {
|
||||||
|
errors.quantity = message;
|
||||||
|
} else if (message.includes('pengajuan') || message.includes('menunggu')) {
|
||||||
|
errors.quantity = message;
|
||||||
|
} else {
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
processing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
return value.toLocaleString('id-ID');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:open="open">
|
||||||
|
<DialogContent class="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="flex items-center gap-2">
|
||||||
|
<ArrowLeftRight class="size-5" />
|
||||||
|
Transfer Stok Ecer
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="rounded-lg border p-3 space-y-1.5">
|
||||||
|
<p class="text-sm font-medium">{{ productName }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground">Varian: {{ variantName }}</p>
|
||||||
|
<div class="flex justify-between text-xs pt-1">
|
||||||
|
<span class="text-muted-foreground">Stok Bagus</span>
|
||||||
|
<span class="font-medium tabular-nums">{{ formatNumber(stockBagus) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-xs">
|
||||||
|
<span class="text-muted-foreground">Stok Ecer</span>
|
||||||
|
<span class="font-medium tabular-nums text-blue-600">{{ formatNumber(stockEcer) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
|
||||||
|
Transfer stok memerlukan persetujuan owner. Stok akan berubah setelah disetujui.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<FieldGroup>
|
||||||
|
<FieldSet class="grid gap-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="ecer-quantity" required>Jumlah Transfer</FieldLabel>
|
||||||
|
<NumberInput
|
||||||
|
id="ecer-quantity"
|
||||||
|
v-model="form.quantity"
|
||||||
|
:max="stockBagus"
|
||||||
|
placeholder="Masukkan jumlah"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">
|
||||||
|
Maksimal: {{ formatNumber(stockBagus) }} pcs
|
||||||
|
</p>
|
||||||
|
<FieldError
|
||||||
|
v-if="errors.quantity"
|
||||||
|
:errors="[errors.quantity]"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="ecer-notes">Catatan</FieldLabel>
|
||||||
|
<Textarea
|
||||||
|
id="ecer-notes"
|
||||||
|
v-model="form.notes"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Contoh: Transfer untuk kebutuhan kasir"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FieldSet>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<DialogFooter class="mt-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="processing"
|
||||||
|
@click="open = false"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" :disabled="processing || !form.quantity || Number(form.quantity) < 1">
|
||||||
|
<Save class="size-4" />
|
||||||
|
{{ processing ? 'Mengirim...' : 'Ajukan Transfer' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
export const StockQuality = {
|
export const StockQuality = {
|
||||||
GOOD: 'good',
|
GOOD: 'good',
|
||||||
REJECT: 'reject',
|
REJECT: 'reject',
|
||||||
|
ECER: 'ecer',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -51,7 +51,7 @@ import { formErrors } from '@/lib/form';
|
|||||||
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
|
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
|
||||||
import { store as syncDraftRoute, resync_prices as resyncPricesRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/orders/draft_items';
|
import { store as syncDraftRoute, resync_prices as resyncPricesRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/orders/draft_items';
|
||||||
import type { Auth } from '@/types/auth';
|
import type { Auth } from '@/types/auth';
|
||||||
import { STOCK_QUALITY_OPTIONS } from '@/types/order';
|
import { STOCK_QUALITY_OPTIONS, STOCK_QUALITY_OPTIONS_CASHIER } from '@/types/order';
|
||||||
import type { EnumOption, OrderCartItem, OrderCatalogItem, SelectOption } from '@/types/order';
|
import type { EnumOption, OrderCartItem, OrderCatalogItem, SelectOption } from '@/types/order';
|
||||||
import type { ProductPriceItem, ProductVariantItem } from '@/types/product';
|
import type { ProductPriceItem, ProductVariantItem } from '@/types/product';
|
||||||
import PosCatalogCard from '../../shared/PosCatalogCard.vue';
|
import PosCatalogCard from '../../shared/PosCatalogCard.vue';
|
||||||
@ -99,8 +99,12 @@ const isMarketingUser = computed(() =>
|
|||||||
(authUser.value?.roles?.includes('marketing-offline') || authUser.value?.roles?.includes('marketing-online')) ?? false,
|
(authUser.value?.roles?.includes('marketing-offline') || authUser.value?.roles?.includes('marketing-online')) ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isCashierUser = computed(() =>
|
||||||
|
authUser.value?.roles?.includes('cashier') ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
const selectedStockQuality = ref<'good' | 'reject'>(StockQuality.GOOD);
|
const selectedStockQuality = ref<'good' | 'reject' | 'ecer'>(isCashierUser.value ? StockQuality.ECER : StockQuality.GOOD);
|
||||||
const cart = ref<OrderCartItem[]>([]);
|
const cart = ref<OrderCartItem[]>([]);
|
||||||
const customerFormOpen = ref(false);
|
const customerFormOpen = ref(false);
|
||||||
const cartDetailOpen = ref(false);
|
const cartDetailOpen = ref(false);
|
||||||
@ -233,7 +237,9 @@ function upsertCartItem(item: OrderCartItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function availableStockForVariant(variant: ProductVariantItem, stockQuality: string): number {
|
function availableStockForVariant(variant: ProductVariantItem, stockQuality: string): number {
|
||||||
return stockQuality === StockQuality.REJECT ? variant.reject_stock : variant.stock;
|
if (stockQuality === StockQuality.REJECT) return variant.reject_stock;
|
||||||
|
if (stockQuality === StockQuality.ECER) return variant.stock_ecer ?? 0;
|
||||||
|
return variant.stock;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredCatalog = computed(() => {
|
const filteredCatalog = computed(() => {
|
||||||
@ -313,7 +319,7 @@ async function addToCart(product: OrderCatalogItem, variant: ProductVariantItem)
|
|||||||
const availableStock = availableStockForVariant(variant, stockQuality);
|
const availableStock = availableStockForVariant(variant, stockQuality);
|
||||||
|
|
||||||
if (availableStock < 1) {
|
if (availableStock < 1) {
|
||||||
toast.error(`Stok ${stockQuality === StockQuality.REJECT ? 'reject' : 'bagus'} tidak tersedia.`);
|
toast.error(`Stok ${stockQuality === StockQuality.REJECT ? 'reject' : stockQuality === StockQuality.ECER ? 'ecer' : 'bagus'} tidak tersedia.`);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -326,7 +332,7 @@ async function addToCart(product: OrderCatalogItem, variant: ProductVariantItem)
|
|||||||
const nextQty = existing ? (Number(existing.quantity) || 0) + 1 : 1;
|
const nextQty = existing ? (Number(existing.quantity) || 0) + 1 : 1;
|
||||||
|
|
||||||
if (nextQty > availableStock) {
|
if (nextQty > availableStock) {
|
||||||
toast.error(`Stok ${stockQuality === StockQuality.REJECT ? 'reject' : 'bagus'} tidak mencukupi.`);
|
toast.error(`Stok ${stockQuality === StockQuality.REJECT ? 'reject' : stockQuality === StockQuality.ECER ? 'ecer' : 'bagus'} tidak mencukupi.`);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -433,7 +439,7 @@ function buildFormData(): FormData {
|
|||||||
formData.append('_method', 'PUT');
|
formData.append('_method', 'PUT');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.customer_id) {
|
if (!isCashierUser.value && form.customer_id) {
|
||||||
formData.append('customer_id', form.customer_id);
|
formData.append('customer_id', form.customer_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -443,20 +449,29 @@ function buildFormData(): FormData {
|
|||||||
formData.append('marketing_id', String(authUser.value.id));
|
formData.append('marketing_id', String(authUser.value.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
formData.append('channel', form.channel);
|
// For cashier, force store channel with ecer price type and cash payment
|
||||||
formData.append('price_type', form.price_type);
|
if (isCashierUser.value) {
|
||||||
formData.append('payment_type', form.payment_type);
|
formData.append('channel', 'store');
|
||||||
|
formData.append('price_type', 'ecer');
|
||||||
|
formData.append('payment_type', OrderPaymentType.CASH);
|
||||||
|
} else {
|
||||||
|
formData.append('channel', form.channel);
|
||||||
|
formData.append('price_type', form.price_type);
|
||||||
|
formData.append('payment_type', form.payment_type);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isStoreChannel.value) {
|
if (!isStoreChannel.value && !isCashierUser.value) {
|
||||||
formData.append('is_affiliate', form.is_affiliate ? '1' : '0');
|
formData.append('is_affiliate', form.is_affiliate ? '1' : '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.channel === OrderChannel.TIKTOK && form.tiktok_order_id) {
|
if (!isCashierUser.value) {
|
||||||
formData.append('tiktok_order_id', form.tiktok_order_id);
|
if (form.channel === OrderChannel.TIKTOK && form.tiktok_order_id) {
|
||||||
}
|
formData.append('tiktok_order_id', form.tiktok_order_id);
|
||||||
|
}
|
||||||
|
|
||||||
if (form.channel === OrderChannel.SHOPEE && form.shopee_order_id) {
|
if (form.channel === OrderChannel.SHOPEE && form.shopee_order_id) {
|
||||||
formData.append('shopee_order_id', form.shopee_order_id);
|
formData.append('shopee_order_id', form.shopee_order_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
formData.append('discount', parseRupiah(form.discount));
|
formData.append('discount', parseRupiah(form.discount));
|
||||||
@ -512,7 +527,7 @@ function submit() {
|
|||||||
<Input v-model="search" placeholder="Cari produk..." class="pl-9" />
|
<Input v-model="search" placeholder="Cari produk..." class="pl-9" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field class="flex-1">
|
<Field v-if="!isCashierUser" class="flex-1">
|
||||||
<Select v-model="selectedStockQuality">
|
<Select v-model="selectedStockQuality">
|
||||||
<SelectTrigger id="stock_quality" class="w-full">
|
<SelectTrigger id="stock_quality" class="w-full">
|
||||||
<SelectValue placeholder="Pilih kualitas stok" />
|
<SelectValue placeholder="Pilih kualitas stok" />
|
||||||
@ -558,9 +573,12 @@ function submit() {
|
|||||||
{{ variant.name }}
|
{{ variant.name }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
<span class="tabular-nums">Bagus: {{ variant.stock }}</span>
|
<span v-if="isCashierUser" class="tabular-nums">Ecer: {{ variant.stock_ecer ?? 0 }}</span>
|
||||||
<span class="mx-1">·</span>
|
<template v-else>
|
||||||
<span class="tabular-nums">Reject: {{ variant.reject_stock ?? 0 }}</span>
|
<span class="tabular-nums">Bagus: {{ variant.stock }}</span>
|
||||||
|
<span class="mx-1">·</span>
|
||||||
|
<span class="tabular-nums">Reject: {{ variant.reject_stock ?? 0 }}</span>
|
||||||
|
</template>
|
||||||
<span class="mx-1">·</span>
|
<span class="mx-1">·</span>
|
||||||
<span v-if="getVariantPrice(variant)" class="tabular-nums">
|
<span v-if="getVariantPrice(variant)" class="tabular-nums">
|
||||||
{{ getVariantPrice(variant)!.price_formatted }}
|
{{ getVariantPrice(variant)!.price_formatted }}
|
||||||
@ -609,7 +627,7 @@ function submit() {
|
|||||||
<form @submit.prevent="submit">
|
<form @submit.prevent="submit">
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<FieldSet class="grid gap-4">
|
<FieldSet class="grid gap-4">
|
||||||
<Field>
|
<Field v-if="!isCashierUser">
|
||||||
<FieldLabel for="channel" required>Channel</FieldLabel>
|
<FieldLabel for="channel" required>Channel</FieldLabel>
|
||||||
<Select v-model="form.channel">
|
<Select v-model="form.channel">
|
||||||
<SelectTrigger id="channel" class="w-full">
|
<SelectTrigger id="channel" class="w-full">
|
||||||
@ -627,7 +645,7 @@ function submit() {
|
|||||||
<FieldError :errors="formErrors(form, 'channel')" />
|
<FieldError :errors="formErrors(form, 'channel')" />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field v-if="isStoreChannel">
|
<Field v-if="isStoreChannel && !isCashierUser">
|
||||||
<FieldLabel for="price_type" required>Tipe Harga</FieldLabel>
|
<FieldLabel for="price_type" required>Tipe Harga</FieldLabel>
|
||||||
<Select v-model="form.price_type">
|
<Select v-model="form.price_type">
|
||||||
<SelectTrigger id="price_type" class="w-full">
|
<SelectTrigger id="price_type" class="w-full">
|
||||||
@ -645,14 +663,14 @@ function submit() {
|
|||||||
<FieldError :errors="formErrors(form, 'price_type')" />
|
<FieldError :errors="formErrors(form, 'price_type')" />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field v-else>
|
<Field v-if="!isStoreChannel && !isCashierUser">
|
||||||
<FieldLabel>Tipe Harga</FieldLabel>
|
<FieldLabel>Tipe Harga</FieldLabel>
|
||||||
<div class="flex h-9 items-center rounded-md border bg-muted/40 px-3 text-sm">
|
<div class="flex h-9 items-center rounded-md border bg-muted/40 px-3 text-sm">
|
||||||
{{ form.channel === OrderChannel.SHOPEE ? 'Shopee' : 'TikTok' }}
|
{{ form.channel === OrderChannel.SHOPEE ? 'Shopee' : 'TikTok' }}
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field v-if="form.channel === OrderChannel.TIKTOK">
|
<Field v-if="form.channel === OrderChannel.TIKTOK && !isCashierUser">
|
||||||
<FieldLabel for="tiktok_order_id" :required="form.channel === OrderChannel.TIKTOK">ID
|
<FieldLabel for="tiktok_order_id" :required="form.channel === OrderChannel.TIKTOK">ID
|
||||||
Pesanan
|
Pesanan
|
||||||
TikTok Shop</FieldLabel>
|
TikTok Shop</FieldLabel>
|
||||||
@ -661,7 +679,7 @@ function submit() {
|
|||||||
<FieldError :errors="formErrors(form, 'tiktok_order_id')" />
|
<FieldError :errors="formErrors(form, 'tiktok_order_id')" />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field v-if="form.channel === OrderChannel.SHOPEE">
|
<Field v-if="form.channel === OrderChannel.SHOPEE && !isCashierUser">
|
||||||
<FieldLabel for="shopee_order_id" :required="form.channel === OrderChannel.SHOPEE">ID
|
<FieldLabel for="shopee_order_id" :required="form.channel === OrderChannel.SHOPEE">ID
|
||||||
Pesanan
|
Pesanan
|
||||||
Shopee</FieldLabel>
|
Shopee</FieldLabel>
|
||||||
@ -670,7 +688,7 @@ function submit() {
|
|||||||
<FieldError :errors="formErrors(form, 'shopee_order_id')" />
|
<FieldError :errors="formErrors(form, 'shopee_order_id')" />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field>
|
<Field v-if="!isCashierUser">
|
||||||
<FieldLabel for="payment_type" required>Tipe Pembayaran</FieldLabel>
|
<FieldLabel for="payment_type" required>Tipe Pembayaran</FieldLabel>
|
||||||
<Select v-model="form.payment_type" :disabled="isMarketplaceChannel">
|
<Select v-model="form.payment_type" :disabled="isMarketplaceChannel">
|
||||||
<SelectTrigger id="payment_type" class="w-full">
|
<SelectTrigger id="payment_type" class="w-full">
|
||||||
@ -687,7 +705,7 @@ function submit() {
|
|||||||
<FieldError :errors="formErrors(form, 'payment_type')" />
|
<FieldError :errors="formErrors(form, 'payment_type')" />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field v-if="isMarketplaceChannel">
|
<Field v-if="isMarketplaceChannel && !isCashierUser">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Switch id="is_affiliate" :model-value="form.is_affiliate"
|
<Switch id="is_affiliate" :model-value="form.is_affiliate"
|
||||||
@update:model-value="form.is_affiliate = $event" />
|
@update:model-value="form.is_affiliate = $event" />
|
||||||
@ -699,7 +717,7 @@ function submit() {
|
|||||||
</p>
|
</p>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field>
|
<Field v-if="!isCashierUser">
|
||||||
<FieldLabel for="customer">Pelanggan</FieldLabel>
|
<FieldLabel for="customer">Pelanggan</FieldLabel>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Select v-model="form.customer_id" class="flex-1">
|
<Select v-model="form.customer_id" class="flex-1">
|
||||||
@ -772,7 +790,8 @@ function submit() {
|
|||||||
·
|
·
|
||||||
{{ item.stock_quality_label ?? (item.stock_quality ===
|
{{ item.stock_quality_label ?? (item.stock_quality ===
|
||||||
StockQuality.REJECT
|
StockQuality.REJECT
|
||||||
? 'Reject' : 'Bagus') }}
|
? 'Reject' : item.stock_quality === StockQuality.ECER
|
||||||
|
? 'Eceran' : 'Bagus') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="ghost" size="icon"
|
<Button type="button" variant="ghost" size="icon"
|
||||||
@ -900,7 +919,7 @@ function submit() {
|
|||||||
<p class="truncate text-xs text-muted-foreground">
|
<p class="truncate text-xs text-muted-foreground">
|
||||||
{{ item.variant_name }}
|
{{ item.variant_name }}
|
||||||
· {{ item.stock_quality_label ?? (item.stock_quality === StockQuality.REJECT ? 'Reject'
|
· {{ item.stock_quality_label ?? (item.stock_quality === StockQuality.REJECT ? 'Reject'
|
||||||
: 'Bagus')
|
: item.stock_quality === StockQuality.ECER ? 'Eceran' : 'Bagus')
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -21,6 +21,7 @@ const initialData = computed(() => ({
|
|||||||
id: variant.id,
|
id: variant.id,
|
||||||
name: variant.name,
|
name: variant.name,
|
||||||
stock: variant.stock,
|
stock: variant.stock,
|
||||||
|
stock_ecer: variant.stock_ecer,
|
||||||
images: variant.images ?? [],
|
images: variant.images ?? [],
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -33,6 +33,7 @@ const props = withDefaults(
|
|||||||
id?: number;
|
id?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
stock?: number | string;
|
stock?: number | string;
|
||||||
|
stock_ecer?: number | string;
|
||||||
images?: Array<{ id: number; url: string; thumb_url: string }>;
|
images?: Array<{ id: number; url: string; thumb_url: string }>;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@ -63,6 +64,7 @@ const {
|
|||||||
client_id: createClientId(),
|
client_id: createClientId(),
|
||||||
name: '',
|
name: '',
|
||||||
stock: '0',
|
stock: '0',
|
||||||
|
stock_ecer: '0',
|
||||||
media: createMediaUploadState(),
|
media: createMediaUploadState(),
|
||||||
}),
|
}),
|
||||||
() => {
|
() => {
|
||||||
@ -71,6 +73,7 @@ const {
|
|||||||
client_id: createClientId(),
|
client_id: createClientId(),
|
||||||
name: '',
|
name: '',
|
||||||
stock: '0',
|
stock: '0',
|
||||||
|
stock_ecer: '0',
|
||||||
media: createMediaUploadState(),
|
media: createMediaUploadState(),
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@ -80,6 +83,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_ecer: variant.stock_ecer != null ? String(variant.stock_ecer) : '0',
|
||||||
media: createMediaUploadState(variant.images ?? []),
|
media: createMediaUploadState(variant.images ?? []),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@ -122,6 +126,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(variant.stock, 10) || 0));
|
formData.append(`variants[${index}][stock]`, String(Number.parseInt(variant.stock, 10) || 0));
|
||||||
|
formData.append(`variants[${index}][stock_ecer]`, String(Number.parseInt(variant.stock_ecer, 10) || 0));
|
||||||
appendMediaToFormData(formData, `variants[${index}]`, variant.media);
|
appendMediaToFormData(formData, `variants[${index}]`, variant.media);
|
||||||
}, props.method);
|
}, props.method);
|
||||||
|
|
||||||
@ -222,6 +227,14 @@ function submit() {
|
|||||||
@update:model-value="setVariantField(variant.client_id, 'stock', String($event))" />
|
@update:model-value="setVariantField(variant.client_id, 'stock', String($event))" />
|
||||||
<FieldError :errors="variantErrors(form, variant.client_id, 'stock')" />
|
<FieldError :errors="variantErrors(form, variant.client_id, 'stock')" />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel :for="`variant_stock_ecer_${variant.client_id}`" required>
|
||||||
|
Stok Ecer
|
||||||
|
</FieldLabel>
|
||||||
|
<NumberInput :id="`variant_stock_ecer_${variant.client_id}`" :model-value="variant.stock_ecer"
|
||||||
|
@update:model-value="setVariantField(variant.client_id, 'stock_ecer', String($event))" />
|
||||||
|
<FieldError :errors="variantErrors(form, variant.client_id, 'stock_ecer')" />
|
||||||
|
</Field>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
import { computed } from 'vue';
|
import { ArrowLeftRight } from '@lucide/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 MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||||
|
import StockEcerTransferModal from '@/components/modal/StockEcerTransferModal.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@ -14,6 +16,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
|
import { useCan } from '@/composables/useCan';
|
||||||
import type {
|
import type {
|
||||||
DataTableFilterDef,
|
DataTableFilterDef,
|
||||||
DataTablePagination,
|
DataTablePagination,
|
||||||
@ -23,7 +26,7 @@ import {
|
|||||||
PRICE_TYPES,
|
PRICE_TYPES,
|
||||||
PRICE_TYPE_LABELS,
|
PRICE_TYPE_LABELS,
|
||||||
} from '@/types/product';
|
} from '@/types/product';
|
||||||
import type { ProductListItem } from '@/types/product';
|
import type { ProductListItem, Variant } from '@/types/product';
|
||||||
import DataTableActions from './data-table-actions.vue';
|
import DataTableActions from './data-table-actions.vue';
|
||||||
import ProductStatusToggle from './product-status-toggle.vue';
|
import ProductStatusToggle from './product-status-toggle.vue';
|
||||||
|
|
||||||
@ -43,6 +46,8 @@ const emit = defineEmits<{
|
|||||||
'filters-reset': [];
|
'filters-reset': [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { can } = useCan();
|
||||||
|
|
||||||
const showingCount = computed(() => props.products.length);
|
const showingCount = computed(() => props.products.length);
|
||||||
|
|
||||||
const paginationSummary = computed(() => {
|
const paginationSummary = computed(() => {
|
||||||
@ -66,6 +71,27 @@ function formatStock(value: number): string {
|
|||||||
function rowNumber(index: number): number {
|
function rowNumber(index: number): number {
|
||||||
return (props.firstItem ?? 1) + index;
|
return (props.firstItem ?? 1) + index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stock Ecer Transfer Modal
|
||||||
|
const transferModalOpen = ref(false);
|
||||||
|
const transferVariantId = ref(0);
|
||||||
|
const transferVariantName = ref('');
|
||||||
|
const transferProductName = ref('');
|
||||||
|
const transferStockBagus = ref(0);
|
||||||
|
const transferStockEcer = ref(0);
|
||||||
|
|
||||||
|
function openTransferModal(product: ProductListItem, variant: Variant) {
|
||||||
|
transferVariantId.value = variant.id;
|
||||||
|
transferVariantName.value = variant.name;
|
||||||
|
transferProductName.value = product.name;
|
||||||
|
transferStockBagus.value = variant.stock;
|
||||||
|
transferStockEcer.value = variant.stock_ecer;
|
||||||
|
transferModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTransferSubmitted() {
|
||||||
|
// Stock won't change until owner approves, no need to update local data
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -106,6 +132,11 @@ function rowNumber(index: number): number {
|
|||||||
{{product.variants.reduce((acc, v) => acc + v.reject_stock, 0)}}
|
{{product.variants.reduce((acc, v) => acc + v.reject_stock, 0)}}
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
|
<span>
|
||||||
|
Total stok ecer <strong class="text-blue-600">
|
||||||
|
{{product.variants.reduce((acc, v) => acc + v.stock_ecer, 0)}}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -123,12 +154,14 @@ function rowNumber(index: number): number {
|
|||||||
<TableHead>Foto</TableHead>
|
<TableHead>Foto</TableHead>
|
||||||
<TableHead>Stok Bagus</TableHead>
|
<TableHead>Stok Bagus</TableHead>
|
||||||
<TableHead>Stok Reject</TableHead>
|
<TableHead>Stok Reject</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="5" class="text-muted-foreground">
|
<TableCell :colspan="can('stocks.view') ? 7 : 6" class="text-muted-foreground">
|
||||||
Belum ada varian
|
Belum ada varian
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -145,6 +178,9 @@ function rowNumber(index: number): number {
|
|||||||
<TableCell class="tabular-nums text-destructive">
|
<TableCell class="tabular-nums text-destructive">
|
||||||
{{ formatStock(variant.reject_stock) }}
|
{{ formatStock(variant.reject_stock) }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell class="tabular-nums text-blue-600">
|
||||||
|
{{ formatStock(variant.stock_ecer) }}
|
||||||
|
</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">
|
||||||
<div v-for="type in PRICE_TYPES" :key="type">
|
<div v-for="type in PRICE_TYPES" :key="type">
|
||||||
@ -163,6 +199,17 @@ function rowNumber(index: number): number {
|
|||||||
</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>
|
||||||
@ -188,4 +235,14 @@ function rowNumber(index: number): number {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<StockEcerTransferModal
|
||||||
|
v-model:open="transferModalOpen"
|
||||||
|
:variant-id="transferVariantId"
|
||||||
|
:variant-name="transferVariantName"
|
||||||
|
:product-name="transferProductName"
|
||||||
|
:stock-bagus="transferStockBagus"
|
||||||
|
:stock-ecer="transferStockEcer"
|
||||||
|
@submitted="onTransferSubmitted"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -101,6 +101,10 @@ export const STOCK_QUALITY_OPTIONS = [
|
|||||||
{ value: 'reject', label: 'Reject' },
|
{ value: 'reject', label: 'Reject' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const STOCK_QUALITY_OPTIONS_CASHIER = [
|
||||||
|
{ value: 'ecer', label: 'Eceran' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export type OrderEditItem = {
|
export type OrderEditItem = {
|
||||||
id: number;
|
id: number;
|
||||||
order_number: string;
|
order_number: string;
|
||||||
|
|||||||
@ -42,6 +42,8 @@ export interface Variant {
|
|||||||
product_id: number;
|
product_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
stock: number;
|
stock: number;
|
||||||
|
reject_stock: number;
|
||||||
|
stock_ecer: number;
|
||||||
prices: Price[];
|
prices: Price[];
|
||||||
images: MediaItem[];
|
images: MediaItem[];
|
||||||
}
|
}
|
||||||
@ -93,5 +95,6 @@ export interface ProductVariantFormItem {
|
|||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
stock: string | number;
|
stock: string | number;
|
||||||
|
stock_ecer: string | number;
|
||||||
media: import('@/types/media').MediaUploadState;
|
media: import('@/types/media').MediaUploadState;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
use App\Http\Controllers\Admin\Manage\PurchaseController;
|
use App\Http\Controllers\Admin\Manage\PurchaseController;
|
||||||
use App\Http\Controllers\Admin\Manage\PurchaseDraftItemController;
|
use App\Http\Controllers\Admin\Manage\PurchaseDraftItemController;
|
||||||
use App\Http\Controllers\Admin\Manage\StockController;
|
use App\Http\Controllers\Admin\Manage\StockController;
|
||||||
|
use App\Http\Controllers\Admin\Manage\StockEcerController;
|
||||||
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;
|
||||||
use App\Http\Controllers\Admin\Master\ProductController;
|
use App\Http\Controllers\Admin\Master\ProductController;
|
||||||
@ -355,6 +356,12 @@
|
|||||||
->name('verify');
|
->name('verify');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::prefix('stock-ecer')->name('stock-ecer.')
|
||||||
|
->middleware('permission:'.Permission::STOCKS_VIEW->value)
|
||||||
|
->group(function () {
|
||||||
|
Route::post('/transfer', [StockEcerController::class, 'transfer'])->name('transfer');
|
||||||
|
});
|
||||||
|
|
||||||
Route::prefix('owner-verifications')->name('owner_verifications.')
|
Route::prefix('owner-verifications')->name('owner_verifications.')
|
||||||
->group(function () {
|
->group(function () {
|
||||||
Route::post('cuttings/{cutting}/approve', [OwnerVerificationController::class, 'approveCutting'])
|
Route::post('cuttings/{cutting}/approve', [OwnerVerificationController::class, 'approveCutting'])
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user