feat: implement stock mutation functionality with service and controller; add pagination and UI components
This commit is contained in:
parent
10db7bc5a2
commit
44dab8271f
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Master\Product;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StockMutationRequest;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\ProductVariant;
|
||||||
|
use App\Services\StockMutationService;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class StockMutationController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private StockMutationService $service = new StockMutationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(StockMutationRequest $request, Product $product, ProductVariant $variant): Response
|
||||||
|
{
|
||||||
|
$perPage = $request->validatedWithDefaults()['perPage'];
|
||||||
|
|
||||||
|
return Inertia::render('admin/master/product/variant/stock-mutations', [
|
||||||
|
'product' => [
|
||||||
|
'id' => $product->id,
|
||||||
|
'name' => $product->name,
|
||||||
|
],
|
||||||
|
'variant' => [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'name' => $variant->name,
|
||||||
|
],
|
||||||
|
'mutations' => $this->service->paginated($variant, $perPage),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Http/Requests/StockMutationRequest.php
Normal file
29
app/Http/Requests/StockMutationRequest.php
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StockMutationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validatedWithDefaults(): array
|
||||||
|
{
|
||||||
|
$validated = $this->validated();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'perPage' => $validated['per_page'] ?? 20,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use App\Services\S3PresignedService;
|
use App\Services\S3PresignedService;
|
||||||
|
use App\Services\StockMutationService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@ -16,6 +17,7 @@ class ProductService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private ProductVariantService $variantService = new ProductVariantService,
|
private ProductVariantService $variantService = new ProductVariantService,
|
||||||
private S3PresignedService $s3Service = new S3PresignedService,
|
private S3PresignedService $s3Service = new S3PresignedService,
|
||||||
|
private StockMutationService $stockMutationService = new StockMutationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function getAll(array $filters = []): Collection
|
public function getAll(array $filters = []): Collection
|
||||||
@ -111,6 +113,8 @@ public function create(array $data): Product
|
|||||||
if (! empty($variantData['photo_keys']) && is_array($variantData['photo_keys'])) {
|
if (! empty($variantData['photo_keys']) && is_array($variantData['photo_keys'])) {
|
||||||
$this->variantService->registerPhotos($variant, $variantData['photo_keys']);
|
$this->variantService->registerPhotos($variant, $variantData['photo_keys']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->stockMutationService->recordInitial($variant, $variantData, 'Stok awal saat pembuatan varian');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $product;
|
return $product;
|
||||||
@ -195,12 +199,14 @@ public function update(Product $product, array $data): Product
|
|||||||
|
|
||||||
if ($variantId) {
|
if ($variantId) {
|
||||||
$variant = $product->productVariants()->findOrFail($variantId);
|
$variant = $product->productVariants()->findOrFail($variantId);
|
||||||
|
$oldData = $variant->only(['stock', 'reject_stock', 'retail_stock']);
|
||||||
$variant->update([
|
$variant->update([
|
||||||
'name' => $variantData['name'],
|
'name' => $variantData['name'],
|
||||||
'stock' => $variantData['stock'],
|
'stock' => $variantData['stock'],
|
||||||
'reject_stock' => $variantData['reject_stock'],
|
'reject_stock' => $variantData['reject_stock'],
|
||||||
'retail_stock' => $variantData['retail_stock'],
|
'retail_stock' => $variantData['retail_stock'],
|
||||||
]);
|
]);
|
||||||
|
$this->stockMutationService->recordAdjustment($variant, $oldData, $variantData, 'Penyesuaian stok saat edit varian');
|
||||||
} else {
|
} else {
|
||||||
$variant = $product->productVariants()->create([
|
$variant = $product->productVariants()->create([
|
||||||
'name' => $variantData['name'],
|
'name' => $variantData['name'],
|
||||||
@ -208,6 +214,7 @@ public function update(Product $product, array $data): Product
|
|||||||
'reject_stock' => $variantData['reject_stock'],
|
'reject_stock' => $variantData['reject_stock'],
|
||||||
'retail_stock' => $variantData['retail_stock'],
|
'retail_stock' => $variantData['retail_stock'],
|
||||||
]);
|
]);
|
||||||
|
$this->stockMutationService->recordInitial($variant, $variantData, 'Stok awal saat pembuatan varian');
|
||||||
}
|
}
|
||||||
|
|
||||||
$variant->productPrices()->delete();
|
$variant->productPrices()->delete();
|
||||||
|
|||||||
@ -5,10 +5,10 @@
|
|||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\ProductPrice;
|
use App\Models\ProductPrice;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Models\StockMutation;
|
|
||||||
use App\Services\Concerns\RegistersMedia;
|
use App\Services\Concerns\RegistersMedia;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use App\Services\S3PresignedService;
|
use App\Services\S3PresignedService;
|
||||||
|
use App\Services\StockMutationService;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
@ -18,6 +18,7 @@ class ProductVariantService
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private S3PresignedService $s3Service = new S3PresignedService,
|
private S3PresignedService $s3Service = new S3PresignedService,
|
||||||
|
private StockMutationService $stockMutationService = new StockMutationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function getForEdit(ProductVariant $variant): array
|
public function getForEdit(ProductVariant $variant): array
|
||||||
@ -47,6 +48,8 @@ public function getForEdit(ProductVariant $variant): array
|
|||||||
public function update(ProductVariant $variant, array $data): ProductVariant
|
public function update(ProductVariant $variant, array $data): ProductVariant
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($variant, $data) {
|
DB::transaction(function () use ($variant, $data) {
|
||||||
|
$oldData = $variant->only(['stock', 'reject_stock', 'retail_stock']);
|
||||||
|
|
||||||
$variant->update([
|
$variant->update([
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
'stock' => $data['stock'],
|
'stock' => $data['stock'],
|
||||||
@ -54,6 +57,8 @@ public function update(ProductVariant $variant, array $data): ProductVariant
|
|||||||
'retail_stock' => $data['retail_stock'],
|
'retail_stock' => $data['retail_stock'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->stockMutationService->recordAdjustment($variant, $oldData, $data, 'Penyesuaian stok saat edit varian');
|
||||||
|
|
||||||
$variant->productPrices()->delete();
|
$variant->productPrices()->delete();
|
||||||
|
|
||||||
foreach ($data['prices'] as $priceData) {
|
foreach ($data['prices'] as $priceData) {
|
||||||
@ -137,29 +142,15 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
|
|||||||
'retail_stock' => $retailBefore + $quantity,
|
'retail_stock' => $retailBefore + $quantity,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
StockMutation::create([
|
$this->stockMutationService->recordTransfer(
|
||||||
'user_id' => auth()->id(),
|
model: $variant,
|
||||||
'stockable_type' => ProductVariant::class,
|
quantity: $quantity,
|
||||||
'stockable_id' => $variant->id,
|
fromQuality: 'good',
|
||||||
'type' => 'out',
|
toQuality: 'retail',
|
||||||
'quantity' => -$quantity,
|
fromBefore: $stockBefore,
|
||||||
'stock_before' => $stockBefore,
|
toBefore: $retailBefore,
|
||||||
'stock_after' => $stockBefore - $quantity,
|
description: $data['description'] ?? 'Transfer stok bagus ke stok ecer',
|
||||||
'stock_quality' => 'good',
|
);
|
||||||
'description' => $data['description'] ?? 'Transfer stok bagus ke stok ecer',
|
|
||||||
]);
|
|
||||||
|
|
||||||
StockMutation::create([
|
|
||||||
'user_id' => auth()->id(),
|
|
||||||
'stockable_type' => ProductVariant::class,
|
|
||||||
'stockable_id' => $variant->id,
|
|
||||||
'type' => 'in',
|
|
||||||
'quantity' => $quantity,
|
|
||||||
'stock_before' => $retailBefore,
|
|
||||||
'stock_after' => $retailBefore + $quantity,
|
|
||||||
'stock_quality' => 'retail',
|
|
||||||
'description' => $data['description'] ?? 'Transfer stok bagus ke stok ecer',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
NotificationService::notify(
|
NotificationService::notify(
|
||||||
|
|||||||
109
app/Services/StockMutationService.php
Normal file
109
app/Services/StockMutationService.php
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\StockMutation;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class StockMutationService
|
||||||
|
{
|
||||||
|
private const QUALITY_MAP = [
|
||||||
|
'stock' => 'good',
|
||||||
|
'reject_stock' => 'reject',
|
||||||
|
'retail_stock' => 'retail',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function recordInitial(Model $model, array $stockData, string $description = 'Stok awal'): void
|
||||||
|
{
|
||||||
|
$userId = auth()->id();
|
||||||
|
|
||||||
|
foreach (self::QUALITY_MAP as $field => $quality) {
|
||||||
|
$quantity = (int) ($stockData[$field] ?? 0);
|
||||||
|
if ($quantity > 0) {
|
||||||
|
StockMutation::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'stockable_type' => get_class($model),
|
||||||
|
'stockable_id' => $model->id,
|
||||||
|
'type' => 'in',
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'stock_before' => 0,
|
||||||
|
'stock_after' => $quantity,
|
||||||
|
'stock_quality' => $quality,
|
||||||
|
'description' => $description,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordAdjustment(Model $model, array $oldData, array $newData, string $description = 'Penyesuaian stok'): void
|
||||||
|
{
|
||||||
|
$userId = auth()->id();
|
||||||
|
|
||||||
|
foreach (self::QUALITY_MAP as $field => $quality) {
|
||||||
|
$old = (int) ($oldData[$field] ?? 0);
|
||||||
|
$new = (int) ($newData[$field] ?? 0);
|
||||||
|
$diff = $new - $old;
|
||||||
|
|
||||||
|
if ($diff !== 0) {
|
||||||
|
StockMutation::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'stockable_type' => get_class($model),
|
||||||
|
'stockable_id' => $model->id,
|
||||||
|
'type' => $diff > 0 ? 'in' : 'out',
|
||||||
|
'quantity' => $diff,
|
||||||
|
'stock_before' => $old,
|
||||||
|
'stock_after' => $new,
|
||||||
|
'stock_quality' => $quality,
|
||||||
|
'description' => $description,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordTransfer(
|
||||||
|
Model $model,
|
||||||
|
int $quantity,
|
||||||
|
string $fromQuality,
|
||||||
|
string $toQuality,
|
||||||
|
int $fromBefore,
|
||||||
|
int $toBefore,
|
||||||
|
string $description = 'Transfer stok',
|
||||||
|
): void {
|
||||||
|
$userId = auth()->id();
|
||||||
|
|
||||||
|
StockMutation::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'stockable_type' => get_class($model),
|
||||||
|
'stockable_id' => $model->id,
|
||||||
|
'type' => 'out',
|
||||||
|
'quantity' => -$quantity,
|
||||||
|
'stock_before' => $fromBefore,
|
||||||
|
'stock_after' => $fromBefore - $quantity,
|
||||||
|
'stock_quality' => $fromQuality,
|
||||||
|
'description' => $description,
|
||||||
|
]);
|
||||||
|
|
||||||
|
StockMutation::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'stockable_type' => get_class($model),
|
||||||
|
'stockable_id' => $model->id,
|
||||||
|
'type' => 'in',
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'stock_before' => $toBefore,
|
||||||
|
'stock_after' => $toBefore + $quantity,
|
||||||
|
'stock_quality' => $toQuality,
|
||||||
|
'description' => $description,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function paginated(Model $model, int $perPage = 20): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return StockMutation::query()
|
||||||
|
->where('stockable_type', get_class($model))
|
||||||
|
->where('stockable_id', $model->id)
|
||||||
|
->with('user:id,username,email')
|
||||||
|
->latest()
|
||||||
|
->paginate($perPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
resources/js/hooks/use-infinite-scroll.ts
Normal file
88
resources/js/hooks/use-infinite-scroll.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { router } from '@inertiajs/react';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
type PaginatedData<T> = {
|
||||||
|
data: T[];
|
||||||
|
current_page: number;
|
||||||
|
last_page: number;
|
||||||
|
per_page: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseInfiniteScrollOptions<T> = {
|
||||||
|
initialData: PaginatedData<T>;
|
||||||
|
fetchUrl: string;
|
||||||
|
perPage?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useInfiniteScroll<T>({
|
||||||
|
initialData,
|
||||||
|
fetchUrl,
|
||||||
|
perPage = 20,
|
||||||
|
}: UseInfiniteScrollOptions<T>) {
|
||||||
|
const [items, setItems] = useState<T[]>(initialData.data);
|
||||||
|
const [currentPage, setCurrentPage] = useState(initialData.current_page);
|
||||||
|
const [lastPage, setLastPage] = useState(initialData.last_page);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (loading || currentPage >= lastPage) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const url = new URL(fetchUrl, window.location.origin);
|
||||||
|
url.searchParams.set('page', String(currentPage + 1));
|
||||||
|
url.searchParams.set('per_page', String(perPage));
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
url.pathname + url.search,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
preserveState: true,
|
||||||
|
replace: true,
|
||||||
|
only: ['mutations'],
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onSuccess: (page: any) => {
|
||||||
|
const newMutations = (page.props as Record<string, unknown>)
|
||||||
|
.mutations as PaginatedData<T>;
|
||||||
|
|
||||||
|
setItems((prev) => [...prev, ...newMutations.data]);
|
||||||
|
setCurrentPage(newMutations.current_page);
|
||||||
|
setLastPage(newMutations.last_page);
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [fetchUrl, currentPage, lastPage, loading, perPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = sentinelRef.current;
|
||||||
|
if (!sentinel) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(sentinel);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [loadMore]);
|
||||||
|
|
||||||
|
const hasNextPage = currentPage < lastPage;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
hasNextPage,
|
||||||
|
sentinelRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,230 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { useInfiniteScroll } from '@/hooks/use-infinite-scroll';
|
||||||
|
import { stockMutations } from '@/routes/admin/master/products/variants';
|
||||||
|
import { Head } from '@inertiajs/react';
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRightLeft,
|
||||||
|
ArrowUp,
|
||||||
|
Loader2,
|
||||||
|
Package,
|
||||||
|
Pencil,
|
||||||
|
ScrollText,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { index as productIndex } from '@/routes/admin/master/products';
|
||||||
|
|
||||||
|
type Mutation = {
|
||||||
|
id: number;
|
||||||
|
type: 'in' | 'out';
|
||||||
|
quantity: number;
|
||||||
|
stock_before: number;
|
||||||
|
stock_after: number;
|
||||||
|
stock_quality: string;
|
||||||
|
description: string | null;
|
||||||
|
created_at: string;
|
||||||
|
user: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
full_name?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type PaginatedMutations = {
|
||||||
|
data: Mutation[];
|
||||||
|
current_page: number;
|
||||||
|
last_page: number;
|
||||||
|
per_page: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
product: { id: number; name: string };
|
||||||
|
variant: { id: number; name: string };
|
||||||
|
mutations: PaginatedMutations;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
return new Intl.NumberFormat('id-ID').format(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Intl.DateTimeFormat('id-ID', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(dateStr));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQualityLabel(quality: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
good: 'Bagus',
|
||||||
|
reject: 'Reject',
|
||||||
|
retail: 'Ecer',
|
||||||
|
};
|
||||||
|
return labels[quality] ?? quality;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQualityColor(quality: string): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
good: 'bg-green-100 text-green-800',
|
||||||
|
reject: 'bg-red-100 text-red-800',
|
||||||
|
retail: 'bg-blue-100 text-blue-800',
|
||||||
|
};
|
||||||
|
return colors[quality] ?? 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeLabel(type: string, quantity: number): string {
|
||||||
|
if (type === 'in') {
|
||||||
|
return quantity >= 0 ? 'Penambahan' : 'Pengurangan';
|
||||||
|
}
|
||||||
|
return quantity < 0 ? 'Pengurangan' : 'Penambahan';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMutationTitle(description: string | null): string {
|
||||||
|
if (!description) return 'Perubahan Stok';
|
||||||
|
if (description.includes('Transfer stok')) return 'Transfer Stok';
|
||||||
|
if (description.includes('Stok awal')) return 'Stok Awal';
|
||||||
|
if (description.includes('Penyesuaian stok')) return 'Edit Varian';
|
||||||
|
return 'Perubahan Stok';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMutationIcon(description: string | null): React.ReactNode {
|
||||||
|
if (description?.includes('Transfer stok')) {
|
||||||
|
return <ArrowRightLeft className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
if (description?.includes('Stok awal')) {
|
||||||
|
return <Package className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
if (description?.includes('Penyesuaian stok')) {
|
||||||
|
return <Pencil className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
return <ScrollText className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StockMutationsPage({
|
||||||
|
product,
|
||||||
|
variant,
|
||||||
|
mutations,
|
||||||
|
}: Props) {
|
||||||
|
const { items, loading, hasNextPage, sentinelRef } = useInfiniteScroll({
|
||||||
|
initialData: mutations,
|
||||||
|
fetchUrl: stockMutations.url({ product: product.id, variant: variant.id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Mutasi Stok" />
|
||||||
|
|
||||||
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Mutasi Stok
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{product.name} — {variant.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<a href={productIndex.url()}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Kembali
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{items.length === 0 && !loading ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<ScrollText className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">
|
||||||
|
Belum ada mutasi stok.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
items.map((mutation) => {
|
||||||
|
const isPositive = mutation.quantity > 0;
|
||||||
|
const qualityColor = getQualityColor(mutation.stock_quality);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={mutation.id}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${isPositive ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'}`}>
|
||||||
|
{isPositive ? (
|
||||||
|
<ArrowUp className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{getMutationTitle(mutation.description)}
|
||||||
|
</span>
|
||||||
|
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${qualityColor}`}>
|
||||||
|
{getQualityLabel(mutation.stock_quality)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{formatDate(mutation.created_at)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{mutation.user?.full_name ?? mutation.user?.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
||||||
|
<span className={isPositive ? 'font-medium text-green-600' : 'font-medium text-red-600'}>
|
||||||
|
{isPositive ? '+' : ''}{formatNumber(mutation.quantity)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{formatNumber(mutation.stock_before)} → {formatNumber(mutation.stock_after)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mutation.description && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{mutation.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasNextPage && (
|
||||||
|
<div ref={sentinelRef} className="flex items-center justify-center py-4">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Memuat data...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasNextPage && items.length > 0 && (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||||
|
Semua data sudah dimuat.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { ArrowRightLeft, Pencil, Trash2 } from 'lucide-react';
|
import { ArrowRightLeft, Pencil, ScrollText, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -17,6 +17,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import type { Product, ProductVariant } from '../columns';
|
import type { Product, ProductVariant } from '../columns';
|
||||||
|
import { stockMutations } from '@/routes/admin/master/products/variants';
|
||||||
import { TransferStockDialog } from './transfer-stock-dialog';
|
import { TransferStockDialog } from './transfer-stock-dialog';
|
||||||
|
|
||||||
function formatCurrency(amount: number): string {
|
function formatCurrency(amount: number): string {
|
||||||
@ -181,6 +182,25 @@ export function VariantSubRow({
|
|||||||
Transfer Stok
|
Transfer Stok
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = stockMutations.url({
|
||||||
|
product: product.id,
|
||||||
|
variant: variant.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ScrollText className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
Mutasi Stok
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Http\Controllers\Admin\Master\CustomerController;
|
use App\Http\Controllers\Admin\Master\CustomerController;
|
||||||
use App\Http\Controllers\Admin\Master\Product\ProductController;
|
use App\Http\Controllers\Admin\Master\Product\ProductController;
|
||||||
use App\Http\Controllers\Admin\Master\Product\ProductVariantController;
|
use App\Http\Controllers\Admin\Master\Product\ProductVariantController;
|
||||||
|
use App\Http\Controllers\Admin\Master\Product\StockMutationController;
|
||||||
use App\Http\Controllers\Admin\Master\SupplierController;
|
use App\Http\Controllers\Admin\Master\SupplierController;
|
||||||
use App\Http\Controllers\Admin\RoleController;
|
use App\Http\Controllers\Admin\RoleController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@ -38,6 +39,7 @@
|
|||||||
Route::get('products/{product}/variants/{variant}/edit', [ProductVariantController::class, 'edit'])->name('products.variants.edit');
|
Route::get('products/{product}/variants/{variant}/edit', [ProductVariantController::class, 'edit'])->name('products.variants.edit');
|
||||||
Route::put('products/{product}/variants/{variant}', [ProductVariantController::class, 'update'])->name('products.variants.update');
|
Route::put('products/{product}/variants/{variant}', [ProductVariantController::class, 'update'])->name('products.variants.update');
|
||||||
Route::post('products/{product}/variants/{variant}/transfer-stock', [ProductVariantController::class, 'transferStock'])->name('products.variants.transfer-stock');
|
Route::post('products/{product}/variants/{variant}/transfer-stock', [ProductVariantController::class, 'transferStock'])->name('products.variants.transfer-stock');
|
||||||
|
Route::get('products/{product}/variants/{variant}/stock-mutations', [StockMutationController::class, 'index'])->name('products.variants.stock-mutations');
|
||||||
Route::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit']);
|
Route::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit']);
|
||||||
Route::resource('customers', CustomerController::class)->except(['show', 'create', 'edit']);
|
Route::resource('customers', CustomerController::class)->except(['show', 'create', 'edit']);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2526,3 +2526,281 @@ function allPriceTypes(): array
|
|||||||
|
|
||||||
$response->assertRedirect(route('admin.master.products.index'));
|
$response->assertRedirect(route('admin.master.products.index'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| STOCK MUTATION AUDIT TRAIL
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('creating product with stock creates initial stock mutations', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$this->post(route('admin.master.products.store'), makeValidProductPayload([
|
||||||
|
'variants' => [[
|
||||||
|
'name' => 'Varian Audit',
|
||||||
|
'stock' => 50,
|
||||||
|
'reject_stock' => 5,
|
||||||
|
'retail_stock' => 10,
|
||||||
|
'photo_keys' => ['product-variant/audit.jpg'],
|
||||||
|
'prices' => allPriceTypes(),
|
||||||
|
]],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$variant = ProductVariant::where('name', 'Varian Audit')->first();
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('stock_mutations', 3);
|
||||||
|
|
||||||
|
$goodMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'good')->first();
|
||||||
|
expect($goodMutation)->not->toBeNull();
|
||||||
|
expect($goodMutation->type)->toBe('in');
|
||||||
|
expect((float) $goodMutation->quantity)->toBe(50.0);
|
||||||
|
expect((float) $goodMutation->stock_before)->toBe(0.0);
|
||||||
|
expect((float) $goodMutation->stock_after)->toBe(50.0);
|
||||||
|
expect($goodMutation->description)->toBe('Stok awal saat pembuatan varian');
|
||||||
|
|
||||||
|
$rejectMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'reject')->first();
|
||||||
|
expect((float) $rejectMutation->quantity)->toBe(5.0);
|
||||||
|
|
||||||
|
$retailMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'retail')->first();
|
||||||
|
expect((float) $retailMutation->quantity)->toBe(10.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creating product with zero stock creates no mutations', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$this->post(route('admin.master.products.store'), makeValidProductPayload([
|
||||||
|
'variants' => [[
|
||||||
|
'name' => 'Varian Kosong',
|
||||||
|
'stock' => 0,
|
||||||
|
'reject_stock' => 0,
|
||||||
|
'retail_stock' => 0,
|
||||||
|
'photo_keys' => ['product-variant/empty.jpg'],
|
||||||
|
'prices' => allPriceTypes(),
|
||||||
|
]],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('stock_mutations', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updating variant stock creates adjustment mutations', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$product = Product::factory()->create();
|
||||||
|
$category = Category::factory()->create();
|
||||||
|
DB::table('product_categories')->insert([
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
]);
|
||||||
|
$variant = ProductVariant::factory()->for($product)->create([
|
||||||
|
'name' => 'Original',
|
||||||
|
'stock' => 100,
|
||||||
|
'reject_stock' => 10,
|
||||||
|
'retail_stock' => 20,
|
||||||
|
]);
|
||||||
|
foreach (allPriceTypes() as $price) {
|
||||||
|
ProductPrice::factory()->create(['variant_id' => $variant->id, 'type' => $price['type'], 'price' => $price['price']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->put(route('admin.master.products.update', $product), makeValidProductPayload([
|
||||||
|
'name' => $product->name,
|
||||||
|
'category_ids' => [$category->id],
|
||||||
|
'variants' => [[
|
||||||
|
'id' => $variant->id,
|
||||||
|
'name' => 'Original',
|
||||||
|
'stock' => 120,
|
||||||
|
'reject_stock' => 10,
|
||||||
|
'retail_stock' => 25,
|
||||||
|
'photo_keys' => ['product-variant/updated.jpg'],
|
||||||
|
'prices' => allPriceTypes(),
|
||||||
|
]],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$goodMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'good')->first();
|
||||||
|
expect($goodMutation)->not->toBeNull();
|
||||||
|
expect($goodMutation->type)->toBe('in');
|
||||||
|
expect((float) $goodMutation->quantity)->toBe(20.0);
|
||||||
|
expect((float) $goodMutation->stock_before)->toBe(100.0);
|
||||||
|
expect((float) $goodMutation->stock_after)->toBe(120.0);
|
||||||
|
|
||||||
|
$retailMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'retail')->first();
|
||||||
|
expect((float) $retailMutation->quantity)->toBe(5.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updating variant via single edit creates adjustment mutations', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$product = Product::factory()->create();
|
||||||
|
$variant = ProductVariant::factory()->for($product)->create([
|
||||||
|
'name' => 'Single Edit',
|
||||||
|
'stock' => 80,
|
||||||
|
'reject_stock' => 5,
|
||||||
|
'retail_stock' => 15,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->put(route('admin.master.products.variants.update', [$product, $variant]), [
|
||||||
|
'name' => 'Single Edit',
|
||||||
|
'stock' => 70,
|
||||||
|
'reject_stock' => 8,
|
||||||
|
'retail_stock' => 15,
|
||||||
|
'photo_keys' => ['product-variant/single-edit.jpg'],
|
||||||
|
'prices' => allPriceTypes(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$goodMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'good')->first();
|
||||||
|
expect($goodMutation)->not->toBeNull();
|
||||||
|
expect($goodMutation->type)->toBe('out');
|
||||||
|
expect((float) $goodMutation->quantity)->toBe(-10.0);
|
||||||
|
|
||||||
|
$rejectMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'reject')->first();
|
||||||
|
expect((float) $rejectMutation->quantity)->toBe(3.0);
|
||||||
|
|
||||||
|
$retailMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'retail')->first();
|
||||||
|
expect($retailMutation)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no mutation created when stock values unchanged', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$product = Product::factory()->create();
|
||||||
|
$category = Category::factory()->create();
|
||||||
|
DB::table('product_categories')->insert([
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
]);
|
||||||
|
$variant = ProductVariant::factory()->for($product)->create([
|
||||||
|
'name' => 'Unchanged',
|
||||||
|
'stock' => 50,
|
||||||
|
'reject_stock' => 10,
|
||||||
|
'retail_stock' => 20,
|
||||||
|
]);
|
||||||
|
foreach (allPriceTypes() as $price) {
|
||||||
|
ProductPrice::factory()->create(['variant_id' => $variant->id, 'type' => $price['type'], 'price' => $price['price']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->put(route('admin.master.products.update', $product), makeValidProductPayload([
|
||||||
|
'name' => 'Updated Name Only',
|
||||||
|
'category_ids' => [$category->id],
|
||||||
|
'variants' => [[
|
||||||
|
'id' => $variant->id,
|
||||||
|
'name' => 'Unchanged',
|
||||||
|
'stock' => 50,
|
||||||
|
'reject_stock' => 10,
|
||||||
|
'retail_stock' => 20,
|
||||||
|
'photo_keys' => ['product-variant/unchanged.jpg'],
|
||||||
|
'prices' => allPriceTypes(),
|
||||||
|
]],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('stock_mutations', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stock mutations record user who performed the action', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$product = Product::factory()->create();
|
||||||
|
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100]);
|
||||||
|
|
||||||
|
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
|
||||||
|
'quantity' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mutation = StockMutation::where('stockable_id', $variant->id)->first();
|
||||||
|
expect($mutation->user_id)->toBe($user->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| STOCK MUTATIONS PAGE
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('guest cannot access stock mutations page', function () {
|
||||||
|
$product = Product::factory()->create();
|
||||||
|
$variant = ProductVariant::factory()->for($product)->create();
|
||||||
|
|
||||||
|
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant]));
|
||||||
|
$response->assertRedirect(route('login'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated user can access stock mutations page', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$product = Product::factory()->create();
|
||||||
|
$variant = ProductVariant::factory()->for($product)->create();
|
||||||
|
|
||||||
|
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant]));
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('admin/master/product/variant/stock-mutations')
|
||||||
|
->has('mutations.data', 0)
|
||||||
|
->where('product.id', $product->id)
|
||||||
|
->where('variant.id', $variant->id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stock mutations page displays mutations for specific variant', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$product = Product::factory()->create();
|
||||||
|
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100]);
|
||||||
|
|
||||||
|
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
|
||||||
|
'quantity' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant]));
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('admin/master/product/variant/stock-mutations')
|
||||||
|
->has('mutations.data', 2)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stock mutations page does not show mutations from other variants', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$product = Product::factory()->create();
|
||||||
|
$variant1 = ProductVariant::factory()->for($product)->create(['stock' => 100]);
|
||||||
|
$variant2 = ProductVariant::factory()->for($product)->create(['stock' => 50]);
|
||||||
|
|
||||||
|
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant1]), [
|
||||||
|
'quantity' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant1]));
|
||||||
|
$response->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('admin/master/product/variant/stock-mutations')
|
||||||
|
->has('mutations.data', 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant2]));
|
||||||
|
$response->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('admin/master/product/variant/stock-mutations')
|
||||||
|
->has('mutations.data', 0)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stock mutations page shows correct product and variant info', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$product = Product::factory()->create(['name' => 'Produk Test']);
|
||||||
|
$variant = ProductVariant::factory()->for($product)->create(['name' => 'Varian Test']);
|
||||||
|
|
||||||
|
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant]));
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(fn (Assert $page) => $page
|
||||||
|
->where('product.name', 'Produk Test')
|
||||||
|
->where('variant.name', 'Varian Test')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user