diff --git a/app/Http/Controllers/Admin/Master/Product/StockMutationController.php b/app/Http/Controllers/Admin/Master/Product/StockMutationController.php new file mode 100644 index 0000000..540ecf7 --- /dev/null +++ b/app/Http/Controllers/Admin/Master/Product/StockMutationController.php @@ -0,0 +1,35 @@ +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), + ]); + } +} diff --git a/app/Http/Requests/StockMutationRequest.php b/app/Http/Requests/StockMutationRequest.php new file mode 100644 index 0000000..f96a201 --- /dev/null +++ b/app/Http/Requests/StockMutationRequest.php @@ -0,0 +1,29 @@ + ['nullable', 'integer', 'min:1', 'max:100'], + ]; + } + + public function validatedWithDefaults(): array + { + $validated = $this->validated(); + + return [ + 'perPage' => $validated['per_page'] ?? 20, + ]; + } +} diff --git a/app/Services/Admin/Master/Product/ProductService.php b/app/Services/Admin/Master/Product/ProductService.php index 1510a56..1016323 100644 --- a/app/Services/Admin/Master/Product/ProductService.php +++ b/app/Services/Admin/Master/Product/ProductService.php @@ -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(); diff --git a/app/Services/Admin/Master/Product/ProductVariantService.php b/app/Services/Admin/Master/Product/ProductVariantService.php index 7fda8ed..761bd39 100644 --- a/app/Services/Admin/Master/Product/ProductVariantService.php +++ b/app/Services/Admin/Master/Product/ProductVariantService.php @@ -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( diff --git a/app/Services/StockMutationService.php b/app/Services/StockMutationService.php new file mode 100644 index 0000000..5618e2e --- /dev/null +++ b/app/Services/StockMutationService.php @@ -0,0 +1,109 @@ + '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); + } +} diff --git a/resources/js/hooks/use-infinite-scroll.ts b/resources/js/hooks/use-infinite-scroll.ts new file mode 100644 index 0000000..43fdad3 --- /dev/null +++ b/resources/js/hooks/use-infinite-scroll.ts @@ -0,0 +1,88 @@ +import { router } from '@inertiajs/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +type PaginatedData = { + data: T[]; + current_page: number; + last_page: number; + per_page: number; + total: number; +}; + +type UseInfiniteScrollOptions = { + initialData: PaginatedData; + fetchUrl: string; + perPage?: number; +}; + +export function useInfiniteScroll({ + initialData, + fetchUrl, + perPage = 20, +}: UseInfiniteScrollOptions) { + const [items, setItems] = useState(initialData.data); + const [currentPage, setCurrentPage] = useState(initialData.current_page); + const [lastPage, setLastPage] = useState(initialData.last_page); + const [loading, setLoading] = useState(false); + const sentinelRef = useRef(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) + .mutations as PaginatedData; + + 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, + }; +} diff --git a/resources/js/pages/admin/master/product/variant/stock-mutations.tsx b/resources/js/pages/admin/master/product/variant/stock-mutations.tsx new file mode 100644 index 0000000..a81c605 --- /dev/null +++ b/resources/js/pages/admin/master/product/variant/stock-mutations.tsx @@ -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 = { + good: 'Bagus', + reject: 'Reject', + retail: 'Ecer', + }; + return labels[quality] ?? quality; +} + +function getQualityColor(quality: string): string { + const colors: Record = { + 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 ; + } + if (description?.includes('Stok awal')) { + return ; + } + if (description?.includes('Penyesuaian stok')) { + return ; + } + return ; +} + +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 ( + <> + + +
+
+
+

+ Mutasi Stok +

+

+ {product.name} — {variant.name} +

+
+ +
+ +
+ {items.length === 0 && !loading ? ( + + + +

+ Belum ada mutasi stok. +

+
+
+ ) : ( + items.map((mutation) => { + const isPositive = mutation.quantity > 0; + const qualityColor = getQualityColor(mutation.stock_quality); + + return ( + + +
+
+ {isPositive ? ( + + ) : ( + + )} +
+ +
+
+ + {getMutationTitle(mutation.description)} + + + {getQualityLabel(mutation.stock_quality)} + +
+ +
+ + {formatDate(mutation.created_at)} + + + {mutation.user?.full_name ?? mutation.user?.username} + +
+ +
+ + {isPositive ? '+' : ''}{formatNumber(mutation.quantity)} + + + {formatNumber(mutation.stock_before)} → {formatNumber(mutation.stock_after)} + +
+ + {mutation.description && ( +

+ {mutation.description} +

+ )} +
+
+
+
+ ); + }) + )} + + {hasNextPage && ( +
+ {loading && ( +
+ + Memuat data... +
+ )} +
+ )} + + {!hasNextPage && items.length > 0 && ( +

+ Semua data sudah dimuat. +

+ )} +
+
+ + ); +} diff --git a/resources/js/pages/admin/master/product/variant/sub-row.tsx b/resources/js/pages/admin/master/product/variant/sub-row.tsx index 99a4eb3..b394060 100644 --- a/resources/js/pages/admin/master/product/variant/sub-row.tsx +++ b/resources/js/pages/admin/master/product/variant/sub-row.tsx @@ -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 + + + + + + Mutasi Stok + +