feat: add stock ecer transfer functionality with new controller, service, and frontend modal integration

This commit is contained in:
Yoga Pangestu 2026-06-28 10:42:57 +07:00
parent 414ce51904
commit 8146da86c6
25 changed files with 703 additions and 38 deletions

View File

@ -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',
}; };
} }
} }

View File

@ -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',
}; };
} }
} }

View 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.',
]);
}
}

View File

@ -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.');
} }

View 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',
];
}
}

View File

@ -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'),
]; ];
} }

View File

@ -24,6 +24,7 @@ protected function casts(): array
return [ return [
'stock' => 'integer', 'stock' => 'integer',
'reject_stock' => 'integer', 'reject_stock' => 'integer',
'stock_ecer' => 'integer',
]; ];
} }

View 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);
}
}

View File

@ -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']);

View File

@ -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'),

View 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.
}
}

View File

@ -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(),

View File

@ -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),

View File

@ -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;
} }
} }

View File

@ -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');
});
}
};

View File

@ -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');
}
};

View 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>

View File

@ -1,4 +1,5 @@
export const StockQuality = { export const StockQuality = {
GOOD: 'good', GOOD: 'good',
REJECT: 'reject', REJECT: 'reject',
ECER: 'ecer',
} as const; } as const;

View File

@ -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>

View File

@ -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 ?? [],
})), })),
})); }));

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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;
} }

View File

@ -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'])