feat: implement retail stock transfer functionality with new controller, request validation, and service logic, while updating related models and UI components

This commit is contained in:
Yoga Pangestu 2026-06-28 22:57:07 +07:00
parent aeb7d1f292
commit 8f66502f25
32 changed files with 262 additions and 156 deletions

View File

@ -226,7 +226,7 @@ ### Controller
PurchaseDraftItemController.php
Stock/
StockController.php
StockRetailController.php
RetailStockController.php
StokOpnameController.php ← modul dengan 1 controller tetap di level parent
OwnerVerificationController.php
```

View File

@ -13,7 +13,7 @@ enum OwnerVerificationAction: string
case DELETE = 'delete';
case TOGGLE_STATUS = 'toggle_status';
case STOCK_VERIFY = 'stock_verify';
case STOCK_RETAIL_TRANSFER = 'stock_retail_transfer';
case RETAIL_STOCK_TRANSFER = 'retail_stock_transfer';
public function label(): string
{
@ -23,7 +23,7 @@ public function label(): string
self::DELETE => 'Hapus',
self::TOGGLE_STATUS => 'Ubah Status',
self::STOCK_VERIFY => 'Verifikasi Stok',
self::STOCK_RETAIL_TRANSFER => 'Transfer Stok Ecer',
self::RETAIL_STOCK_TRANSFER => 'Transfer Stok Ecer',
};
}
}

View File

@ -3,21 +3,21 @@
namespace App\Http\Controllers\Admin\Manage\Stock;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\StockRetailTransferRequest;
use App\Services\Manage\StockRetailService;
use App\Http\Requests\Admin\Manage\RetailStockTransferRequest;
use App\Services\Manage\RetailStockService;
use Illuminate\Http\JsonResponse;
class StockRetailController extends Controller
class RetailStockController extends Controller
{
public function __construct(
private readonly StockRetailService $stockRetailService,
private readonly RetailStockService $retailStockService,
) {}
public function transfer(StockRetailTransferRequest $request): JsonResponse
public function transfer(RetailStockTransferRequest $request): JsonResponse
{
$validated = $request->validated();
$this->stockRetailService->transfer(
$this->retailStockService->transfer(
$validated['product_variant_id'],
$validated['quantity'],
$request->user(),

View File

@ -6,7 +6,7 @@
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StockRetailTransferRequest extends FormRequest
class RetailStockTransferRequest extends FormRequest
{
public function authorize(): bool
{

View File

@ -42,7 +42,7 @@ public function rules(): array
],
'variants.*.name' => ['required', 'string', 'max:200'],
'variants.*.stock' => ['required', 'integer', 'min:0'],
'variants.*.stock_retail' => ['required', 'integer', 'min:0'],
'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
...$this->variantImageRules(),
];
}
@ -60,7 +60,7 @@ public function attributes(): array
'variants' => 'Varian',
'variants.*.name' => 'Nama Varian',
'variants.*.stock' => 'Stok',
'variants.*.stock_retail' => 'Stok Ecer',
'variants.*.retail_stock' => 'Stok Ecer',
...$this->variantImageAttributes('variants', 'Foto Varian'),
];
}

View File

@ -65,7 +65,7 @@ public function totalRejectStockFormatted(): Attribute
public function totalRetailStockFormatted(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->variants->sum('stock_retail'), 0, ',', '.'),
get: fn () => number_format($this->variants->sum('retail_stock'), 0, ',', '.'),
);
}

View File

@ -15,7 +15,7 @@
use Spatie\MediaLibrary\HasMedia;
#[Guarded(['id'])]
#[Appends(['stock_formatted', 'reject_stock_formatted', 'stock_retail_formatted'])]
#[Appends(['stock_formatted', 'reject_stock_formatted', 'retail_stock_formatted'])]
class ProductVariant extends Model implements HasMedia
{
// 1. Use Trait
@ -29,7 +29,7 @@ protected function casts(): array
return [
'reject_stock' => 'integer',
'stock' => 'integer',
'stock_retail' => 'integer',
'retail_stock' => 'integer',
];
}
@ -48,10 +48,10 @@ public function stockFormatted(): Attribute
);
}
public function stockRetailFormatted(): Attribute
public function retailStockFormatted(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->stock_retail, 0, ',', '.'),
get: fn () => number_format($this->retail_stock, 0, ',', '.'),
);
}

View File

@ -50,8 +50,10 @@ public function stockFormatted(): Attribute
return Attribute::make(
get: function () {
$formatted = rtrim(rtrim(number_format((float) $this->stock, 4, ',', '.'), '0'), ',');
$unitAbbreviation = $this->attributes['unit_abbreviation']
?? $this->rawMaterial?->unit?->abbreviation();
return "{$formatted} {$this->rawMaterial->unit->abbreviation()}";
return "{$formatted} {$unitAbbreviation}";
},
);
}

View File

@ -7,9 +7,9 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Guarded(['id'])]
class StockRetailHistory extends Model
class RetailStockHistory extends Model
{
protected $table = 'stock_retail_histories';
protected $table = 'retail_stock_histories';
public $timestamps = false;
@ -21,8 +21,8 @@ protected function casts(): array
'quantity' => 'integer',
'stock_after' => 'integer',
'stock_before' => 'integer',
'stock_retail_after' => 'integer',
'stock_retail_before' => 'integer',
'retail_stock_after' => 'integer',
'retail_stock_before' => 'integer',
];
}

View File

@ -117,7 +117,7 @@ public function rawMaterialCatalog(?Cutting $cutting = null, ?User $user = null)
->with([
'prices' => fn ($query) => $query
->orderBy('created_at')
->with(['rawMaterial:id,unit', 'media']),
->with('media'),
])
->where(function (Builder $query) use ($selectedPriceIds): void {
$query->active();
@ -132,11 +132,10 @@ public function rawMaterialCatalog(?Cutting $cutting = null, ?User $user = null)
->orderBy('name')
->get()
->each(function (RawMaterial $rawMaterial): void {
$rawMaterial->prices->each(function (RawMaterialPrice $price): void {
$price->setAttribute(
'images',
MediaPresenter::collection($price, 'images'),
);
$rawMaterial->prices->each(function (RawMaterialPrice $price) use ($rawMaterial): void {
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
$price->setAttribute('unit_abbreviation', $rawMaterial->unit->abbreviation());
$price->unsetRelation('rawMaterial');
});
});
}

View File

@ -52,7 +52,7 @@ public function stockColumn(ProductStockQuality $quality): string
return match ($quality) {
ProductStockQuality::GOOD => 'stock',
ProductStockQuality::REJECT => 'reject_stock',
ProductStockQuality::RETAIL => 'stock_retail',
ProductStockQuality::RETAIL => 'retail_stock',
};
}

View File

@ -36,7 +36,7 @@ public function __construct(
private readonly PurchaseService $purchaseService,
private readonly PushNotificationService $pushNotificationService,
private readonly MarketplaceService $marketplaceService,
private readonly StockRetailService $stockRetailService,
private readonly RetailStockService $retailStockService,
) {}
public function hasPendingMarketplaceVerification(): bool
@ -283,7 +283,7 @@ private function rejectVerificationRequest(OwnerVerificationRequest $request): v
RawMaterial::class => $this->rawMaterialService->rejectVerificationRequest($request),
Purchase::class => $this->purchaseService->rejectVerificationRequest($request),
MarketplaceSettings::class => null,
ProductVariant::class => $this->stockRetailService->rejectStockRetailTransfer($request),
ProductVariant::class => $this->retailStockService->rejectRetailStockTransfer($request),
default => throw ValidationException::withMessages([
'subject_type' => 'Tipe data verifikasi tidak didukung.',
]),
@ -297,7 +297,7 @@ private function applyVerificationRequest(OwnerVerificationRequest $request): vo
RawMaterial::class => $this->rawMaterialService->applyVerificationRequest($request),
Purchase::class => $this->purchaseService->applyVerificationRequest($request),
MarketplaceSettings::class => $this->marketplaceService->applyVerificationRequest($request),
ProductVariant::class => $this->stockRetailService->applyStockRetailTransfer($request),
ProductVariant::class => $this->retailStockService->applyRetailStockTransfer($request),
default => throw ValidationException::withMessages([
'subject_type' => 'Tipe data verifikasi tidak didukung.',
]),

View File

@ -97,7 +97,7 @@ public function catalogItems(?Purchase $purchase = null, ?User $user = null): Co
->with([
'prices' => fn ($query) => $query
->orderBy('created_at')
->with(['rawMaterial:id,unit', 'media']),
->with('media'),
])
->where(function (Builder $query) use ($purchasePriceIds): void {
$query->active();
@ -112,11 +112,10 @@ public function catalogItems(?Purchase $purchase = null, ?User $user = null): Co
->orderBy('name')
->get()
->each(function (RawMaterial $rawMaterial): void {
$rawMaterial->prices->each(function (RawMaterialPrice $price): void {
$price->setAttribute(
'images',
MediaPresenter::collection($price, 'images'),
);
$rawMaterial->prices->each(function (RawMaterialPrice $price) use ($rawMaterial): void {
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
$price->setAttribute('unit_abbreviation', $rawMaterial->unit->abbreviation());
$price->unsetRelation('rawMaterial');
});
});
}

View File

@ -7,14 +7,14 @@
use App\Enums\Permission;
use App\Models\OwnerVerificationRequest;
use App\Models\ProductVariant;
use App\Models\StockRetailHistory;
use App\Models\RetailStockHistory;
use App\Models\User;
use App\Services\System\PushNotificationService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class StockRetailService
class RetailStockService
{
public function __construct(
private readonly PushNotificationService $pushNotificationService,
@ -47,7 +47,7 @@ public function transfer(int $variantId, int $quantity, User $user, ?string $not
$existingPending = OwnerVerificationRequest::query()
->where('subject_type', ProductVariant::class)
->where('subject_id', $variant->id)
->where('action', OwnerVerificationAction::STOCK_RETAIL_TRANSFER)
->where('action', OwnerVerificationAction::RETAIL_STOCK_TRANSFER)
->where('status', OwnerVerificationStatus::PENDING)
->exists();
@ -59,7 +59,7 @@ public function transfer(int $variantId, int $quantity, User $user, ?string $not
try {
$request = OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::STOCK_RETAIL_TRANSFER,
'action' => OwnerVerificationAction::RETAIL_STOCK_TRANSFER,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => ProductVariant::class,
'subject_id' => $variant->id,
@ -67,12 +67,12 @@ public function transfer(int $variantId, int $quantity, User $user, ?string $not
'payload' => [
'old' => [
'stock' => $variant->stock,
'stock_retail' => $variant->stock_retail,
'retail_stock' => $variant->retail_stock,
],
'new' => [
'quantity' => $quantity,
'stock' => $variant->stock - $quantity,
'stock_retail' => $variant->stock_retail + $quantity,
'retail_stock' => $variant->retail_stock + $quantity,
'notes' => $notes,
'variant_name' => $variant->name,
'product_name' => $variant->product?->name ?? '-',
@ -110,7 +110,7 @@ public function transfer(int $variantId, int $quantity, User $user, ?string $not
}
}
public function applyStockRetailTransfer(OwnerVerificationRequest $request): void
public function applyRetailStockTransfer(OwnerVerificationRequest $request): void
{
$payload = $request->payload ?? [];
$newData = $payload['new'] ?? [];
@ -138,26 +138,26 @@ private function executeTransfer(ProductVariant $variant, int $quantity, User $u
->firstOrFail();
$stockBefore = $variant->stock;
$stockRetailBefore = $variant->stock_retail;
$retailStockBefore = $variant->retail_stock;
$variant->decrement('stock', $quantity);
$variant->increment('stock_retail', $quantity);
$variant->increment('retail_stock', $quantity);
StockRetailHistory::create([
RetailStockHistory::create([
'product_variant_id' => $variant->id,
'user_id' => $user->id,
'quantity' => $quantity,
'stock_before' => $stockBefore,
'stock_retail_before' => $stockRetailBefore,
'retail_stock_before' => $retailStockBefore,
'stock_after' => $stockBefore - $quantity,
'stock_retail_after' => $stockRetailBefore + $quantity,
'retail_stock_after' => $retailStockBefore + $quantity,
'notes' => $notes,
'created_at' => now(),
]);
});
}
public function rejectStockRetailTransfer(OwnerVerificationRequest $request): void
public function rejectRetailStockTransfer(OwnerVerificationRequest $request): void
{
// No-op: nothing was changed yet, so nothing to rollback.
}

View File

@ -74,12 +74,12 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
$pendingRequest = $product->pendingOwnerVerificationRequest;
// Also check for pending stock retail transfer on variants
// Also check for pending retail stock 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_RETAIL_TRANSFER)
->where('action', OwnerVerificationAction::RETAIL_STOCK_TRANSFER)
->pending()
->latest()
->first();
@ -132,7 +132,7 @@ public function create(array $validated, User $user): void
$variant = $product->variants()->create([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'stock_retail' => $variantData['stock_retail'],
'retail_stock' => $variantData['retail_stock'],
]);
$this->syncVariantImages($variant, $variantData, $index);
@ -195,7 +195,7 @@ public function update(Product $product, array $validated, User $user): void
$variant = $product->variants()->create([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'stock_retail' => $variantData['stock_retail'],
'retail_stock' => $variantData['retail_stock'],
]);
$this->syncVariantImages($variant, $variantData, $index);
}
@ -436,7 +436,7 @@ private function applyPayloadToProduct(
$variant->update([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'stock_retail' => $variantData['stock_retail'],
'retail_stock' => $variantData['retail_stock'],
]);
if ($verificationRequest !== null) {
@ -449,7 +449,7 @@ private function applyPayloadToProduct(
$variant = $product->variants()->create([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'stock_retail' => $variantData['stock_retail'],
'retail_stock' => $variantData['retail_stock'],
]);
if ($verificationRequest !== null) {
@ -595,7 +595,7 @@ private function snapshotProduct(Product $product): array
'id' => $variant->id,
'name' => $variant->name,
'stock' => $variant->stock,
'stock_retail' => $variant->stock_retail,
'retail_stock' => $variant->retail_stock,
])
->all(),
]);
@ -623,7 +623,7 @@ private function buildPayloadFromValidated(array $validated): array
'id' => $variantData['id'] ?? null,
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'stock_retail' => $variantData['stock_retail'],
'retail_stock' => $variantData['retail_stock'],
'remove_media_ids' => $variantData['remove_media_ids'] ?? [],
])
->all(),

View File

@ -35,7 +35,7 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $st
'pendingOwnerVerificationRequest',
'prices' => fn ($query) => $query
->orderBy('created_at')
->with(['rawMaterial:id,unit', 'media']),
->with('media'),
])
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
@ -73,11 +73,10 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $st
->paginate(25)
->withQueryString()
->through(function (RawMaterial $rawMaterial) {
$rawMaterial->prices->each(function (RawMaterialPrice $price): void {
$price->setAttribute(
'images',
MediaPresenter::collection($price, 'images'),
);
$rawMaterial->prices->each(function (RawMaterialPrice $price) use ($rawMaterial): void {
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
$price->setAttribute('unit_abbreviation', $rawMaterial->unit->abbreviation());
$price->unsetRelation('rawMaterial');
});
$pendingRequest = $rawMaterial->pendingOwnerVerificationRequest;
@ -101,8 +100,10 @@ public function findForEdit(RawMaterial $rawMaterial): RawMaterial
'prices' => fn ($query) => $query->orderBy('created_at')->with('media'),
]);
$rawMaterial->prices->each(function (RawMaterialPrice $price): void {
$rawMaterial->prices->each(function (RawMaterialPrice $price) use ($rawMaterial): void {
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
$price->setAttribute('unit_abbreviation', $rawMaterial->unit->abbreviation());
$price->unsetRelation('rawMaterial');
});
return $rawMaterial;

View File

@ -499,7 +499,7 @@ public function getProductStock(): array
->selectRaw('
SUM(product_variants.stock) as total_stock,
SUM(product_variants.reject_stock) as total_reject,
SUM(product_variants.stock_retail) as total_retail,
SUM(product_variants.retail_stock) as total_retail,
COUNT(product_variants.id) as total_variants,
COUNT(DISTINCT products.id) as total_products
')

View File

@ -77,7 +77,7 @@ private static function label(string $field): string
'prices' => 'Varian Harga',
'quantity' => 'Jumlah Transfer',
'stock' => 'Stok Bagus',
'stock_retail' => 'Stok Ecer',
'retail_stock' => 'Stok Ecer',
'variant_name' => 'Varian',
'product_name' => 'Produk',
'tiktok_shop_platform_commission' => 'TikTok Shop - Komisi Platform',
@ -186,7 +186,7 @@ private static function presentValue(string $field, mixed $value): mixed
return implode(', ', $value);
}
if (in_array($field, ['quantity', 'stock', 'stock_retail'], true)) {
if (in_array($field, ['quantity', 'stock', 'retail_stock'], true)) {
return (int) $value;
}

View File

@ -16,7 +16,7 @@ public function up(): void
$table->string('name', 200);
$table->unsignedInteger('stock')->default(0);
$table->unsignedInteger('reject_stock')->default(0);
$table->unsignedInteger('stock_retail')->default(0);
$table->unsignedInteger('retail_stock')->default(0);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();

View File

@ -8,7 +8,7 @@
{
public function up(): void
{
Schema::create('stock_retail_histories', function (Blueprint $table) {
Schema::create('retail_stock_histories', function (Blueprint $table) {
$table->id();
$table->foreignId('product_variant_id')->constrained('product_variants')->cascadeOnDelete();
@ -16,9 +16,9 @@ public function up(): void
$table->unsignedInteger('quantity');
$table->unsignedInteger('stock_before');
$table->unsignedInteger('stock_retail_before');
$table->unsignedInteger('retail_stock_before');
$table->unsignedInteger('stock_after');
$table->unsignedInteger('stock_retail_after');
$table->unsignedInteger('retail_stock_after');
$table->string('notes')->nullable();
$table->timestamp('created_at')->useCurrent();
@ -27,6 +27,6 @@ public function up(): void
public function down(): void
{
Schema::dropIfExists('stock_retail_histories');
Schema::dropIfExists('retail_stock_histories');
}
};

View File

@ -0,0 +1,33 @@
<script setup lang="ts">
import { ArrowLeftRight } from '@lucide/vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
defineProps<{
tooltip?: string;
disabled?: boolean;
tooltipClass?: string;
}>();
const emit = defineEmits<{
click: [];
}>();
</script>
<template>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="size-8"
:disabled="disabled"
@click="emit('click')"
>
<ArrowLeftRight class="size-4" />
<span class="sr-only">{{ tooltip || 'Transfer Stok Ecer' }}</span>
</Button>
</TooltipTrigger>
<TooltipContent :class="tooltipClass">{{ tooltip || 'Transfer Stok Ecer' }}</TooltipContent>
</Tooltip>
</template>

View File

@ -7,5 +7,6 @@ export { default as RowAdjustAction } from './RowAdjustAction.vue';
export { default as RowStatusAction } from './RowStatusAction.vue';
export { default as RowPrintAction } from './RowPrintAction.vue';
export { default as RowDetailAction } from './RowDetailAction.vue';
export { default as RowTransferAction } from './RowTransferAction.vue';
export { default as CreateButton } from './CreateButton.vue';
export { default as BackButton } from './BackButton.vue';

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ArrowLeftRight, Save } from '@lucide/vue';
import { reactive, ref, watch } from 'vue';
import { Save } from '@lucide/vue';
import { computed, reactive, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { NumberInput } from '@/components/form/number-input';
import { Button } from '@/components/ui/button';
@ -18,9 +18,24 @@ import {
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api';
import { transfer } from '@/routes/admin/manage/stock-retail';
import { transfer } from '@/routes/admin/manage/retail-stock';
interface VariantOption {
id: number;
name: string;
stock: number;
retail_stock: number;
}
const open = defineModel<boolean>('open', { default: false });
@ -28,8 +43,9 @@ const props = defineProps<{
variantId: number;
variantName: string;
productName: string;
stockBagus: number;
stockRetail: number;
goodStock: number;
retailStock: number;
variants?: VariantOption[];
}>();
const emit = defineEmits<{
@ -39,6 +55,22 @@ const emit = defineEmits<{
const processing = ref(false);
const errors = reactive<Record<string, string>>({});
const selectedVariantId = ref(0);
const hasMultipleVariants = computed(() => (props.variants?.length ?? 0) > 1);
const activeVariant = computed(() => {
if (hasMultipleVariants.value && selectedVariantId.value > 0) {
return props.variants?.find((v) => v.id === selectedVariantId.value);
}
return undefined;
});
const displayVariantName = computed(() => activeVariant.value?.name ?? props.variantName);
const displayGoodStock = computed(() => activeVariant.value?.stock ?? props.goodStock);
const displayRetailStock = computed(() => activeVariant.value?.retail_stock ?? props.retailStock);
const form = reactive({
quantity: '',
notes: '',
@ -48,14 +80,30 @@ function resetForm() {
form.quantity = '';
form.notes = '';
Object.keys(errors).forEach((key) => delete errors[key]);
if (hasMultipleVariants.value) {
selectedVariantId.value = 0;
}
}
watch(open, (isOpen) => {
if (isOpen) {
resetForm();
if (!hasMultipleVariants.value && props.variantId > 0) {
selectedVariantId.value = props.variantId;
}
}
});
const submitVariantId = computed(() => {
if (hasMultipleVariants.value) {
return selectedVariantId.value;
}
return props.variantId;
});
async function submit() {
Object.keys(errors).forEach((key) => delete errors[key]);
processing.value = true;
@ -64,7 +112,7 @@ async function submit() {
await apiFetch(transfer.url(), {
method: 'POST',
body: JSON.stringify({
product_variant_id: props.variantId,
product_variant_id: submitVariantId.value,
quantity: Number(form.quantity),
notes: form.notes || null,
}),
@ -108,14 +156,35 @@ function formatNumber(value: number): string {
<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>
<Field v-if="hasMultipleVariants">
<FieldLabel for="transfer-variant" required>Pilih Varian</FieldLabel>
<Select :model-value="selectedVariantId ? String(selectedVariantId) : undefined" @update:model-value="selectedVariantId = Number($event)">
<SelectTrigger id="transfer-variant" class="w-full">
<SelectValue placeholder="Pilih varian" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="variant in variants"
:key="variant.id"
:value="String(variant.id)"
>
{{ variant.name }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
<p v-if="!hasMultipleVariants" class="text-xs text-muted-foreground">Varian: {{ displayVariantName }}</p>
<div class="flex justify-between text-xs pt-1">
<span class="text-muted-foreground">Stok Bagus</span>
<span class="font-medium tabular-nums">{{ formatNumber(stockBagus) }}</span>
<span class="font-medium tabular-nums">{{ formatNumber(displayGoodStock) }}</span>
</div>
<div class="flex justify-between text-xs">
<span class="text-muted-foreground">Stok Ecer</span>
<span class="font-medium tabular-nums text-blue-600">{{ formatNumber(stockRetail) }}</span>
<span class="font-medium tabular-nums text-blue-600">{{ formatNumber(displayRetailStock) }}</span>
</div>
</div>
@ -131,7 +200,7 @@ function formatNumber(value: number): string {
<NumberInput
id="retail-quantity"
v-model="form.quantity"
:max="stockBagus"
:max="displayGoodStock"
placeholder="Masukkan jumlah"
/>
<FieldError
@ -161,7 +230,7 @@ function formatNumber(value: number): string {
>
Batal
</Button>
<Button type="submit" :disabled="processing || !form.quantity || Number(form.quantity) < 1">
<Button type="submit" :disabled="processing || !form.quantity || Number(form.quantity) < 1 || (hasMultipleVariants && !selectedVariantId)">
<Save class="size-4" />
{{ processing ? 'Mengirim...' : 'Ajukan Transfer' }}
</Button>

View File

@ -110,7 +110,7 @@ const emit = defineEmits<{
{{ variant.name }}
</p>
<p class="text-xs text-muted-foreground">
<span v-if="isCashierUser" class="tabular-nums">Ecer: {{ variant.stock_retail ?? 0 }}</span>
<span v-if="isCashierUser" class="tabular-nums">Ecer: {{ variant.retail_stock ?? 0 }}</span>
<template v-else>
<span class="tabular-nums">Bagus: {{ variant.stock }}</span>
<span class="mx-1">·</span>

View File

@ -80,7 +80,7 @@ export function useOrderPosCart(options: {
}
if (stockQuality === StockQuality.RETAIL) {
return variant.stock_retail ?? 0;
return variant.retail_stock ?? 0;
}
return variant.stock;

View File

@ -21,7 +21,7 @@ const initialData = computed(() => ({
id: variant.id,
name: variant.name,
stock: variant.stock,
stock_retail: variant.stock_retail,
retail_stock: variant.retail_stock,
images: variant.images ?? [],
})),
}));

View File

@ -42,7 +42,7 @@ const {
client_id: createClientId(),
name: '',
stock: '0',
stock_retail: '0',
retail_stock: '0',
media: createMediaUploadState(),
}),
() => {
@ -51,7 +51,7 @@ const {
client_id: createClientId(),
name: '',
stock: '0',
stock_retail: '0',
retail_stock: '0',
media: createMediaUploadState(),
}];
}
@ -61,7 +61,7 @@ const {
id: variant.id,
name: variant.name ?? '',
stock: variant.stock != null ? String(variant.stock) : '0',
stock_retail: variant.stock_retail != null ? String(variant.stock_retail) : '0',
retail_stock: variant.retail_stock != null ? String(variant.retail_stock) : '0',
media: createMediaUploadState(variant.images ?? []),
}));
},
@ -100,7 +100,7 @@ function buildFormData(): FormData {
appendToFormData(formData, (formData, index, variant) => {
formData.append(`variants[${index}][name]`, variant.name.trim());
formData.append(`variants[${index}][stock]`, String(Number.parseInt(String(variant.stock), 10) || 0));
formData.append(`variants[${index}][stock_retail]`, String(Number.parseInt(String(variant.stock_retail), 10) || 0));
formData.append(`variants[${index}][retail_stock]`, String(Number.parseInt(String(variant.retail_stock), 10) || 0));
appendMediaToFormData(formData, `variants[${index}]`, variant.media);
}, props.method);
@ -153,7 +153,7 @@ function submit() {
@remove="removeVariant(variant.client_id)"
@update:name="setVariantField(variant.client_id, 'name', $event)"
@update:stock="setVariantField(variant.client_id, 'stock', $event)"
@update:stock-retail="setVariantField(variant.client_id, 'stock_retail', $event)"
@update:retail-stock="setVariantField(variant.client_id, 'retail_stock', $event)"
@update:media="setVariantField(variant.client_id, 'media', $event)"
/>

View File

@ -30,7 +30,7 @@ const emit = defineEmits<{
remove: [];
'update:name': [value: string];
'update:stock': [value: string];
'update:stock-retail': [value: string];
'update:retail-stock': [value: string];
'update:media': [value: MediaUploadState];
}>();
</script>
@ -79,15 +79,15 @@ const emit = defineEmits<{
<FieldError :errors="variantErrors(variant.client_id, 'stock')" />
</Field>
<Field>
<FieldLabel :for="`variant_stock_retail_${variant.client_id}`" required>
<FieldLabel :for="`variant_retail_stock_${variant.client_id}`" required>
Stok Ecer
</FieldLabel>
<NumberInput
:id="`variant_stock_retail_${variant.client_id}`"
:model-value="variant.stock_retail"
@update:model-value="emit('update:stock-retail', String($event))"
:id="`variant_retail_stock_${variant.client_id}`"
:model-value="variant.retail_stock"
@update:model-value="emit('update:retail-stock', String($event))"
/>
<FieldError :errors="variantErrors(variant.client_id, 'stock_retail')" />
<FieldError :errors="variantErrors(variant.client_id, 'retail_stock')" />
</Field>
</FieldSet>

View File

@ -1,13 +1,10 @@
<script setup lang="ts">
import { ArrowLeftRight } from '@lucide/vue';
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { DataTableEmpty } from '@/components/data-table';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import StockRetailTransferModal from '@/components/modal/StockRetailTransferModal.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
@ -56,27 +53,6 @@ const paginationSummary = usePaginationSummary(() => props.pagination, showingCo
function rowNumber(index: number): number {
return groupedTableRowNumber(props.firstItem, index);
}
// Stock Retail Transfer Modal
const transferModalOpen = ref(false);
const transferVariantId = ref(0);
const transferVariantName = ref('');
const transferProductName = ref('');
const transferStockBagus = ref(0);
const transferStockRetail = ref(0);
function openTransferModal(product: ProductListItem, variant: Variant) {
transferVariantId.value = variant.id;
transferVariantName.value = variant.name;
transferProductName.value = product.name;
transferStockBagus.value = variant.stock;
transferStockRetail.value = variant.stock_retail;
transferModalOpen.value = true;
}
function onTransferSubmitted() {
// Stock won't change until owner approves, no need to update local data
}
</script>
<template>
@ -141,12 +117,11 @@ function onTransferSubmitted() {
<TableHead>Stok Reject</TableHead>
<TableHead>Stok Ecer</TableHead>
<TableHead>Harga</TableHead>
<TableHead v-if="can('stocks.view')" class="w-[60px]">Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="!product.variants.length" :key="`${product.id}-empty`">
<TableCell :colspan="can('stocks.view') ? 7 : 6" class="text-muted-foreground">
<TableCell :colspan="6" class="text-muted-foreground">
Belum ada varian
</TableCell>
</TableRow>
@ -164,7 +139,7 @@ function onTransferSubmitted() {
{{ variant.reject_stock_formatted }}
</TableCell>
<TableCell class="tabular-nums text-blue-600">
{{ variant.stock_retail_formatted }}
{{ variant.retail_stock_formatted }}
</TableCell>
<TableCell>
<div v-if="variant.prices?.length" class="space-y-0.5 text-xs">
@ -184,17 +159,6 @@ function onTransferSubmitted() {
</div>
<span v-else class="text-xs text-muted-foreground">Belum ada harga</span>
</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>
</TableBody>
</Table>
@ -209,14 +173,4 @@ function onTransferSubmitted() {
:pagination-links="paginationLinks"
/>
</div>
<StockRetailTransferModal
v-model:open="transferModalOpen"
:variant-id="transferVariantId"
:variant-name="transferVariantName"
:product-name="transferProductName"
:stock-bagus="transferStockBagus"
:stock-retail="transferStockRetail"
@submitted="onTransferSubmitted"
/>
</template>

View File

@ -1,5 +1,7 @@
<script setup lang="ts">
import { RowDeleteAction, RowEditAction } from '@/components/button';
import { ref } from 'vue';
import { RowDeleteAction, RowEditAction, RowTransferAction } from '@/components/button';
import RetailStockTransferModal from '@/components/modal/RetailStockTransferModal.vue';
import OwnerVerificationRowActions from '@/components/owner-verification/OwnerVerificationRowActions.vue';
import { useCan } from '@/composables/useCan';
import { edit, destroy } from '@/routes/admin/master/products';
@ -10,6 +12,35 @@ const props = defineProps<{
}>();
const { can } = useCan();
const transferModalOpen = ref(false);
const transferVariantId = ref(0);
const transferVariantName = ref('');
const transferGoodStock = ref(0);
const transferRetailStock = ref(0);
function openTransferModal(variantId: number, variantName: string, goodStock: number, retailStock: number) {
transferVariantId.value = variantId;
transferVariantName.value = variantName;
transferGoodStock.value = goodStock;
transferRetailStock.value = retailStock;
transferModalOpen.value = true;
}
function handleTransferClick() {
const variants = props.product.variants ?? [];
if (variants.length === 1) {
const v = variants[0];
openTransferModal(v.id, v.name, v.stock, v.retail_stock);
} else if (variants.length > 1) {
transferVariantId.value = 0;
transferVariantName.value = '';
transferGoodStock.value = 0;
transferRetailStock.value = 0;
transferModalOpen.value = true;
}
}
</script>
<template>
@ -18,6 +49,12 @@ const { can } = useCan();
:id="product.pending_request_id" :title="product.name" subject-label="Produk"
:action-label="product.pending_request_action_label" />
<RowTransferAction
v-if="can('stocks.view')"
:disabled="product.has_pending_request"
:tooltip="product.has_pending_request ? 'Menunggu verifikasi owner' : 'Transfer Stok Ecer'"
@click="handleTransferClick"
/>
<RowEditAction v-if="can('products.update')" :href="edit.url(product.id)"
:disabled="product.has_pending_request"
:tooltip="product.has_pending_request ? 'Menunggu verifikasi owner' : 'Ubah'" />
@ -27,4 +64,15 @@ const { can } = useCan();
:description="`Pengajuan hapus produk ${product.name} akan dikirim ke owner untuk verifikasi.`"
error-message="Gagal mengajukan penghapusan produk." />
</div>
<RetailStockTransferModal
v-model:open="transferModalOpen"
:variant-id="transferVariantId"
:variant-name="transferVariantName"
:product-name="product.name"
:good-stock="transferGoodStock"
:retail-stock="transferRetailStock"
:variants="(product.variants ?? []).map(v => ({ id: v.id, name: v.name, stock: v.stock, retail_stock: v.retail_stock }))"
@submitted="() => {}"
/>
</template>

View File

@ -43,8 +43,8 @@ export interface Variant {
stock_formatted: string;
reject_stock: number;
reject_stock_formatted: string;
stock_retail: number;
stock_retail_formatted: string;
retail_stock: number;
retail_stock_formatted: string;
prices: Price[];
images: MediaItem[];
}
@ -91,7 +91,7 @@ export interface ProductVariantFormItem {
id?: number;
name: string;
stock: string | number;
stock_retail: string | number;
retail_stock: string | number;
media: MediaUploadState;
[key: string]: unknown;
}
@ -104,7 +104,7 @@ export type ProductFormInitialData = {
id?: number;
name?: string;
stock?: number | string;
stock_retail?: number | string;
retail_stock?: number | string;
images?: MediaItem[];
}>;
};

View File

@ -21,8 +21,8 @@
use App\Http\Controllers\Admin\Manage\OwnerVerificationController;
use App\Http\Controllers\Admin\Manage\Purchase\PurchaseController;
use App\Http\Controllers\Admin\Manage\Purchase\PurchaseDraftItemController;
use App\Http\Controllers\Admin\Manage\Stock\RetailStockController;
use App\Http\Controllers\Admin\Manage\Stock\StockController;
use App\Http\Controllers\Admin\Manage\Stock\StockRetailController;
use App\Http\Controllers\Admin\Manage\StokOpnameController;
use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController;
@ -357,10 +357,10 @@
->name('verify');
});
Route::prefix('stock-retail')->name('stock-retail.')
Route::prefix('retail-stock')->name('retail-stock.')
->middleware('permission:'.Permission::STOCKS_VIEW->value)
->group(function () {
Route::post('/transfer', [StockRetailController::class, 'transfer'])->name('transfer');
Route::post('/transfer', [RetailStockController::class, 'transfer'])->name('transfer');
});
Route::prefix('stok-opnames')->name('stok-opnames.')