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\Services\NotificationService;
|
||||
use App\Services\S3PresignedService;
|
||||
use App\Services\StockMutationService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -16,6 +17,7 @@ class ProductService
|
||||
public function __construct(
|
||||
private ProductVariantService $variantService = new ProductVariantService,
|
||||
private S3PresignedService $s3Service = new S3PresignedService,
|
||||
private StockMutationService $stockMutationService = new StockMutationService,
|
||||
) {}
|
||||
|
||||
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'])) {
|
||||
$this->variantService->registerPhotos($variant, $variantData['photo_keys']);
|
||||
}
|
||||
|
||||
$this->stockMutationService->recordInitial($variant, $variantData, 'Stok awal saat pembuatan varian');
|
||||
}
|
||||
|
||||
return $product;
|
||||
@ -195,12 +199,14 @@ public function update(Product $product, array $data): Product
|
||||
|
||||
if ($variantId) {
|
||||
$variant = $product->productVariants()->findOrFail($variantId);
|
||||
$oldData = $variant->only(['stock', 'reject_stock', 'retail_stock']);
|
||||
$variant->update([
|
||||
'name' => $variantData['name'],
|
||||
'stock' => $variantData['stock'],
|
||||
'reject_stock' => $variantData['reject_stock'],
|
||||
'retail_stock' => $variantData['retail_stock'],
|
||||
]);
|
||||
$this->stockMutationService->recordAdjustment($variant, $oldData, $variantData, 'Penyesuaian stok saat edit varian');
|
||||
} else {
|
||||
$variant = $product->productVariants()->create([
|
||||
'name' => $variantData['name'],
|
||||
@ -208,6 +214,7 @@ public function update(Product $product, array $data): Product
|
||||
'reject_stock' => $variantData['reject_stock'],
|
||||
'retail_stock' => $variantData['retail_stock'],
|
||||
]);
|
||||
$this->stockMutationService->recordInitial($variant, $variantData, 'Stok awal saat pembuatan varian');
|
||||
}
|
||||
|
||||
$variant->productPrices()->delete();
|
||||
|
||||
@ -5,10 +5,10 @@
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductPrice;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\StockMutation;
|
||||
use App\Services\Concerns\RegistersMedia;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\S3PresignedService;
|
||||
use App\Services\StockMutationService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@ -18,6 +18,7 @@ class ProductVariantService
|
||||
|
||||
public function __construct(
|
||||
private S3PresignedService $s3Service = new S3PresignedService,
|
||||
private StockMutationService $stockMutationService = new StockMutationService,
|
||||
) {}
|
||||
|
||||
public function getForEdit(ProductVariant $variant): array
|
||||
@ -47,6 +48,8 @@ public function getForEdit(ProductVariant $variant): array
|
||||
public function update(ProductVariant $variant, array $data): ProductVariant
|
||||
{
|
||||
DB::transaction(function () use ($variant, $data) {
|
||||
$oldData = $variant->only(['stock', 'reject_stock', 'retail_stock']);
|
||||
|
||||
$variant->update([
|
||||
'name' => $data['name'],
|
||||
'stock' => $data['stock'],
|
||||
@ -54,6 +57,8 @@ public function update(ProductVariant $variant, array $data): ProductVariant
|
||||
'retail_stock' => $data['retail_stock'],
|
||||
]);
|
||||
|
||||
$this->stockMutationService->recordAdjustment($variant, $oldData, $data, 'Penyesuaian stok saat edit varian');
|
||||
|
||||
$variant->productPrices()->delete();
|
||||
|
||||
foreach ($data['prices'] as $priceData) {
|
||||
@ -137,29 +142,15 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
|
||||
'retail_stock' => $retailBefore + $quantity,
|
||||
]);
|
||||
|
||||
StockMutation::create([
|
||||
'user_id' => auth()->id(),
|
||||
'stockable_type' => ProductVariant::class,
|
||||
'stockable_id' => $variant->id,
|
||||
'type' => 'out',
|
||||
'quantity' => -$quantity,
|
||||
'stock_before' => $stockBefore,
|
||||
'stock_after' => $stockBefore - $quantity,
|
||||
'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',
|
||||
]);
|
||||
$this->stockMutationService->recordTransfer(
|
||||
model: $variant,
|
||||
quantity: $quantity,
|
||||
fromQuality: 'good',
|
||||
toQuality: 'retail',
|
||||
fromBefore: $stockBefore,
|
||||
toBefore: $retailBefore,
|
||||
description: $data['description'] ?? 'Transfer stok bagus ke stok ecer',
|
||||
);
|
||||
});
|
||||
|
||||
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 { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -17,6 +17,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { Product, ProductVariant } from '../columns';
|
||||
import { stockMutations } from '@/routes/admin/master/products/variants';
|
||||
import { TransferStockDialog } from './transfer-stock-dialog';
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
@ -181,6 +182,25 @@ export function VariantSubRow({
|
||||
Transfer Stok
|
||||
</TooltipContent>
|
||||
</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>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Http\Controllers\Admin\Master\CustomerController;
|
||||
use App\Http\Controllers\Admin\Master\Product\ProductController;
|
||||
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\RoleController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@ -38,6 +39,7 @@
|
||||
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::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('customers', CustomerController::class)->except(['show', 'create', 'edit']);
|
||||
});
|
||||
|
||||
@ -2526,3 +2526,281 @@ function allPriceTypes(): array
|
||||
|
||||
$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