From cf0e77215953a3aab2285bf242b0388bc7652ada Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sun, 2 Aug 2026 22:34:10 +0700 Subject: [PATCH] feat: implement restock management functionality - Add RestockIndex component for displaying and managing restocks. - Create RestockCardRow component for rendering individual restock items. - Implement RestockItemSubRow component for displaying detailed item information. - Define routes for restock management in web.php. - Create RestockTest to cover various scenarios for restock creation, updating, and deletion. - Ensure proper handling of permissions for restock actions. - Add validation for restock data and ensure correct relationships are maintained. --- app/Enums/ProductStockQuality.php | 11 +- .../Admin/Manage/RestockController.php | 73 ++ .../Requests/Admin/Manage/RestockRequest.php | 39 + app/Models/OrderItem.php | 10 +- app/Models/Restock.php | 16 +- app/Models/StokOpnameItem.php | 10 +- app/Services/Admin/Manage/RestockService.php | 276 ++++++ database/factories/RestockFactory.php | 2 +- database/factories/StockMutationFactory.php | 2 +- database/seeders/RolePermissionSeeder.php | 3 + resources/js/components/app-sidebar.tsx | 3 +- resources/js/hooks/use-restock-draft.ts | 75 ++ resources/js/lib/restock-draft.ts | 67 ++ .../js/pages/admin/manage/restock/columns.tsx | 67 ++ .../js/pages/admin/manage/restock/create.tsx | 707 +++++++++++++++ .../js/pages/admin/manage/restock/edit.tsx | 644 ++++++++++++++ .../js/pages/admin/manage/restock/index.tsx | 162 ++++ .../admin/manage/restock/restock-card.tsx | 197 ++++ .../admin/manage/restock/restock-sub-row.tsx | 124 +++ routes/web.php | 2 + tests/Feature/Admin/Manage/RestockTest.php | 838 ++++++++++++++++++ 21 files changed, 3297 insertions(+), 31 deletions(-) create mode 100644 app/Http/Controllers/Admin/Manage/RestockController.php create mode 100644 app/Http/Requests/Admin/Manage/RestockRequest.php create mode 100644 app/Services/Admin/Manage/RestockService.php create mode 100644 resources/js/hooks/use-restock-draft.ts create mode 100644 resources/js/lib/restock-draft.ts create mode 100644 resources/js/pages/admin/manage/restock/columns.tsx create mode 100644 resources/js/pages/admin/manage/restock/create.tsx create mode 100644 resources/js/pages/admin/manage/restock/edit.tsx create mode 100644 resources/js/pages/admin/manage/restock/index.tsx create mode 100644 resources/js/pages/admin/manage/restock/restock-card.tsx create mode 100644 resources/js/pages/admin/manage/restock/restock-sub-row.tsx create mode 100644 tests/Feature/Admin/Manage/RestockTest.php diff --git a/app/Enums/ProductStockQuality.php b/app/Enums/ProductStockQuality.php index 84d329c..26f7b2c 100644 --- a/app/Enums/ProductStockQuality.php +++ b/app/Enums/ProductStockQuality.php @@ -9,6 +9,13 @@ enum ProductStockQuality: string use HasValues; case GOOD = 'good'; - case BAD = 'bad'; - case DAMAGED = 'damaged'; + case REJECT = 'reject'; + + public function label(): string + { + return match ($this) { + self::GOOD => 'Bagus', + self::REJECT => 'Reject', + }; + } } diff --git a/app/Http/Controllers/Admin/Manage/RestockController.php b/app/Http/Controllers/Admin/Manage/RestockController.php new file mode 100644 index 0000000..b0b94f9 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/RestockController.php @@ -0,0 +1,73 @@ + $this->service->paginated( + ...$request->validatedWithDefaults(), + ), + ]); + } + + public function create(): Response + { + return Inertia::render('admin/manage/restock/create', [ + 'data' => $this->service->getForCreate(), + ]); + } + + public function store(RestockRequest $request): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->create($request->validated()), + 'Restock berhasil ditambahkan.', + 'admin.manage.restocks.index', + 'admin.manage.restocks.create' + ); + } + + public function edit(Restock $restock): Response + { + return Inertia::render('admin/manage/restock/edit', [ + 'restock' => $this->service->getForEdit($restock), + 'data' => $this->service->getForCreate(), + ]); + } + + public function update(RestockRequest $request, Restock $restock): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->update($restock, $request->validated()), + 'Restock berhasil diperbarui.', + 'admin.manage.restocks.index', + 'admin.manage.restocks.edit', + ['restock' => $restock] + ); + } + + public function destroy(Restock $restock): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->delete($restock), + 'Restock berhasil dihapus.', + 'admin.manage.restocks.index' + ); + } +} diff --git a/app/Http/Requests/Admin/Manage/RestockRequest.php b/app/Http/Requests/Admin/Manage/RestockRequest.php new file mode 100644 index 0000000..dea50c7 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/RestockRequest.php @@ -0,0 +1,39 @@ + ['sometimes', 'required', Rule::in(ProductStockQuality::values())], + 'items' => ['required', 'array', 'min:1'], + 'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'], + 'items.*.quantity' => ['required', 'integer', 'min:1'], + 'notes' => ['nullable', 'string', 'max:100'], + 'photo_key' => ['nullable', 'string', 'max:500'], + ]; + } + + public function attributes(): array + { + return [ + 'stock_type' => 'Jenis Stok', + 'items' => 'Item Produk', + 'items.*.product_variant_id' => 'Varian Produk', + 'items.*.quantity' => 'Jumlah', + 'notes' => 'Keterangan', + 'photo_key' => 'Foto', + ]; + } +} diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php index 6df9a9d..1d23ec4 100644 --- a/app/Models/OrderItem.php +++ b/app/Models/OrderItem.php @@ -27,15 +27,9 @@ protected function casts(): array } #[Scope] - protected function bad(Builder $query): void + protected function reject(Builder $query): void { - $query->where('stock_quality', ProductStockQuality::BAD); - } - - #[Scope] - protected function damaged(Builder $query): void - { - $query->where('stock_quality', ProductStockQuality::DAMAGED); + $query->where('stock_quality', ProductStockQuality::REJECT); } #[Scope] diff --git a/app/Models/Restock.php b/app/Models/Restock.php index 14ff44e..f737421 100644 --- a/app/Models/Restock.php +++ b/app/Models/Restock.php @@ -11,11 +11,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; #[Guarded(['id'])] -class Restock extends Model +class Restock extends Model implements HasMedia { - use HasFactory, SoftDeletes; + use HasFactory, InteractsWithMedia, SoftDeletes; protected function casts(): array { @@ -27,15 +29,9 @@ protected function casts(): array } #[Scope] - protected function bad(Builder $query): void + protected function reject(Builder $query): void { - $query->where('stock_type', ProductStockQuality::BAD); - } - - #[Scope] - protected function damaged(Builder $query): void - { - $query->where('stock_type', ProductStockQuality::DAMAGED); + $query->where('stock_type', ProductStockQuality::REJECT); } #[Scope] diff --git a/app/Models/StokOpnameItem.php b/app/Models/StokOpnameItem.php index 1943fc3..acb2768 100644 --- a/app/Models/StokOpnameItem.php +++ b/app/Models/StokOpnameItem.php @@ -26,15 +26,9 @@ protected function casts(): array } #[Scope] - protected function bad(Builder $query): void + protected function reject(Builder $query): void { - $query->where('stock_quality', ProductStockQuality::BAD); - } - - #[Scope] - protected function damaged(Builder $query): void - { - $query->where('stock_quality', ProductStockQuality::DAMAGED); + $query->where('stock_quality', ProductStockQuality::REJECT); } #[Scope] diff --git a/app/Services/Admin/Manage/RestockService.php b/app/Services/Admin/Manage/RestockService.php new file mode 100644 index 0000000..75e281c --- /dev/null +++ b/app/Services/Admin/Manage/RestockService.php @@ -0,0 +1,276 @@ +value => 'stock', + ProductStockQuality::REJECT->value => 'reject_stock', + ]; + + public function __construct( + private S3PresignedService $s3Service = new S3PresignedService, + ) {} + + public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator + { + $paginator = Restock::query() + ->select('id', 'created_by_id', 'subtotal', 'total', 'notes', 'stock_type', 'created_at') + ->with([ + 'createdBy:id', + 'createdBy.userProfile:id,user_id,full_name', + 'restockItems:id,restock_id,product_variant_id,quantity,unit_price,subtotal', + 'restockItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock', + 'restockItems.productVariant.product:id,name', + ]) + ->when($search, function ($q) use ($search) { + $q->whereHas('restockItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%")) + ->orWhere('notes', 'like', "%{$search}%"); + }) + ->orderBy($sort, $direction) + ->paginate($perPage); + + $paginator->getCollection()->each(function (Restock $restock) { + $restock->restockItems->each(function (RestockItem $item) { + if (! $item->productVariant) { + return; + } + + $media = $item->productVariant->getFirstMedia('photos'); + $item->productVariant->photo_url = $media + ? $this->s3Service->getTemporaryUrl($media->file_name) + : null; + }); + }); + + return $paginator; + } + + public function getForCreate(): array + { + return [ + 'products' => Product::query() + ->select('id', 'name', 'status') + ->with([ + 'productVariants:id,product_id,name,stock,reject_stock', + 'productVariants.productPrices:id,variant_id,type,price', + ]) + ->orderBy('name') + ->get() + ->each(function (Product $product) { + $product->productVariants->each(function (ProductVariant $variant) { + $media = $variant->getFirstMedia('photos'); + $variant->photo_url = $media + ? $this->s3Service->getTemporaryUrl($media->file_name) + : null; + + $capitalPrice = $variant->productPrices + ->first(fn ($price) => $price->type === PriceType::CAPITAL); + $variant->capital_price = $capitalPrice?->price ?? 0; + }); + }), + ]; + } + + public function getForEdit(Restock $restock): array + { + $restock->load('restockItems.productVariant.product'); + + $media = $restock->getFirstMedia('photos'); + + return [ + 'id' => $restock->id, + 'stock_type' => $restock->stock_type->value, + 'notes' => $restock->notes, + 'photo_key' => $media?->file_name, + 'photo_url' => $media + ? $this->s3Service->getTemporaryUrl($media->file_name) + : null, + 'items' => $restock->restockItems->map(fn (RestockItem $item) => [ + 'id' => $item->id, + 'product_variant_id' => $item->product_variant_id, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + ])->values(), + ]; + } + + public function create(array $data): Restock + { + return DB::transaction(function () use ($data) { + $now = now(); + $subtotal = 0; + $stockType = $data['stock_type'] ?? ProductStockQuality::GOOD->value; + + $itemRows = $this->buildItemRows($data['items'], $now, $subtotal); + + $restock = Restock::create([ + 'created_by_id' => auth()->id(), + 'subtotal' => $subtotal, + 'total' => $subtotal, + 'notes' => $data['notes'] ?? null, + 'stock_type' => $stockType, + ]); + + foreach ($itemRows as &$row) { + $row['restock_id'] = $restock->id; + } + DB::table('restock_items')->insert($itemRows); + + $this->applyStock($data['items'], $stockType, 1); + $this->syncPhoto($restock, $data); + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Admin Toko'], + title: 'Restock Baru', + body: 'Restock '.($stockType === ProductStockQuality::GOOD->value ? 'produk' : 'reject').' sebesar Rp '.number_format($subtotal, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.', + url: route('admin.manage.restocks.index'), + ); + + return $restock; + }); + } + + public function update(Restock $restock, array $data): Restock + { + return DB::transaction(function () use ($restock, $data) { + $restock->load('restockItems'); + + $restock->restockItems->each(function (RestockItem $item) use ($restock) { + $this->adjustVariantStock($item->product_variant_id, $item->quantity, -1, $restock->stock_type->value); + }); + + $restock->restockItems()->delete(); + + $now = now(); + $subtotal = 0; + $stockType = $data['stock_type'] ?? $restock->stock_type->value; + + $itemRows = $this->buildItemRows($data['items'], $now, $subtotal); + + foreach ($itemRows as &$row) { + $row['restock_id'] = $restock->id; + } + DB::table('restock_items')->insert($itemRows); + + $restock->update([ + 'subtotal' => $subtotal, + 'total' => $subtotal, + 'notes' => $data['notes'] ?? null, + 'stock_type' => $stockType, + ]); + + $this->applyStock($data['items'], $stockType, 1); + $this->syncPhoto($restock, $data); + + return $restock; + }); + } + + public function delete(Restock $restock): bool + { + return DB::transaction(function () use ($restock) { + $restock->load('restockItems'); + + $restock->restockItems->each(function (RestockItem $item) use ($restock) { + $this->adjustVariantStock($item->product_variant_id, $item->quantity, -1, $restock->stock_type->value); + }); + + $restock->restockItems()->delete(); + $restock->clearMediaCollection('photos'); + $restock->delete(); + + return true; + }); + } + + private function buildItemRows(array $items, $now, int &$subtotal): array + { + $variantIds = collect($items)->pluck('product_variant_id')->unique()->all(); + $capitalPrices = ProductVariant::query() + ->whereKey($variantIds) + ->with('productPrices:id,variant_id,type,price') + ->get() + ->mapWithKeys(function (ProductVariant $variant) { + $capitalPrice = $variant->productPrices + ->first(fn ($price) => $price->type === PriceType::CAPITAL); + + return [$variant->id => $capitalPrice?->price ?? 0]; + }); + + return collect($items)->map(function ($item) use ($now, $capitalPrices, &$subtotal) { + $quantity = (int) $item['quantity']; + $unitPrice = (int) ($capitalPrices[$item['product_variant_id']] ?? 0); + $itemSubtotal = $unitPrice * $quantity; + $subtotal += $itemSubtotal; + + return [ + 'restock_id' => null, + 'user_id' => auth()->id(), + 'product_variant_id' => $item['product_variant_id'], + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'subtotal' => $itemSubtotal, + 'created_at' => $now, + 'updated_at' => $now, + ]; + })->toArray(); + } + + private function applyStock(array $items, string $stockType, int $sign): void + { + foreach ($items as $item) { + $this->adjustVariantStock($item['product_variant_id'], $item['quantity'], $sign, $stockType); + } + } + + private function adjustVariantStock(int $variantId, int $quantity, int $sign, string $stockType): void + { + $field = self::QUALITY_STOCK_MAP[$stockType] ?? 'stock'; + + if ($sign > 0) { + ProductVariant::whereKey($variantId)->increment($field, $quantity); + } else { + ProductVariant::whereKey($variantId)->decrement($field, $quantity); + } + } + + private function syncPhoto(Restock $restock, array $data): void + { + if (! array_key_exists('photo_key', $data)) { + return; + } + + $currentKey = $restock->getFirstMedia('photos')?->file_name; + + if ($data['photo_key'] === $currentKey) { + return; + } + + $restock->clearMediaCollection('photos'); + + if (! empty($data['photo_key'])) { + $this->registerMedia( + model: $restock, + s3Key: $data['photo_key'], + collectionName: 'photos', + orderColumn: 1, + ); + } + } +} diff --git a/database/factories/RestockFactory.php b/database/factories/RestockFactory.php index 7f0ad48..fe065bf 100644 --- a/database/factories/RestockFactory.php +++ b/database/factories/RestockFactory.php @@ -14,7 +14,7 @@ public function definition(): array 'subtotal' => fake()->numberBetween(50000, 5000000), 'total' => fake()->numberBetween(50000, 5000000), 'notes' => fake()->sentence(), - 'stock_type' => fake()->randomElement(['good', 'bad', 'damaged']), + 'stock_type' => fake()->randomElement(['good', 'reject']), ]; } } diff --git a/database/factories/StockMutationFactory.php b/database/factories/StockMutationFactory.php index 9ce95a4..b939a87 100644 --- a/database/factories/StockMutationFactory.php +++ b/database/factories/StockMutationFactory.php @@ -19,7 +19,7 @@ public function definition(): array 'quantity' => $quantity, 'stock_before' => $stockBefore, 'stock_after' => $stockBefore + $quantity, - 'stock_quality' => fake()->randomElement(['good', 'bad', 'damaged']), + 'stock_quality' => fake()->randomElement(['good', 'reject', 'retail']), 'description' => fake()->sentence(), 'user_id' => User::factory(), ]; diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index a5acbee..01b3b29 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -28,6 +28,7 @@ public function run(): void 'attendance' => ['view', 'check-in', 'check-out', 'by-date'], 'settings' => ['view', 'update-system', 'update-homepage', 'update-social-media', 'update-marketplace', 'update-hr'], 'purchase' => ['view', 'create', 'update', 'delete'], + 'restock' => ['view', 'create', 'update', 'delete'], ]; foreach ($permissions as $module => $actions) { @@ -55,6 +56,7 @@ public function run(): void 'Admin Bahan Baku' => array_filter($allPermissions, function ($p) { return str_starts_with($p, 'supplier.') || str_starts_with($p, 'purchase.') + || str_starts_with($p, 'restock.') || $p === 'category.view' || $p === 'customer.view' || $p === 'cash-account.view'; @@ -64,6 +66,7 @@ public function run(): void return str_starts_with($p, 'category.') || str_starts_with($p, 'customer.') || str_starts_with($p, 'expense.') + || str_starts_with($p, 'restock.') || $p === 'cash-account.view' || $p === 'leave-request.view' || $p === 'leave-request.create' diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index a747883..a1b1c76 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -25,6 +25,7 @@ import { index as customersIndex } from '@/routes/admin/master/customers'; import { index as productsIndex } from '@/routes/admin/master/products'; import { index as rawMaterialsIndex } from '@/routes/admin/master/raw-materials'; import { index as purchasesIndex } from '@/routes/admin/manage/purchases'; +import { index as restocksIndex } from '@/routes/admin/manage/restocks'; import { index as suppliersIndex } from '@/routes/admin/master/suppliers'; import { index as rolesIndex } from '@/routes/admin/settings/roles'; import { Link, router } from '@inertiajs/react'; @@ -78,7 +79,7 @@ const masterItems: NavMenuItem[] = [ const kelolaItems: NavMenuItem[] = [ { title: 'Belanja', href: purchasesIndex.url(), icon: ShoppingCart }, { title: 'Cutting', href: '#', icon: Scissors }, - { title: 'Restock', href: '#', icon: RefreshCw }, + { title: 'Restock', href: restocksIndex.url(), icon: RefreshCw }, { title: 'Stok Opname', href: '#', icon: ClipboardCheck }, ]; diff --git a/resources/js/hooks/use-restock-draft.ts b/resources/js/hooks/use-restock-draft.ts new file mode 100644 index 0000000..aa4196f --- /dev/null +++ b/resources/js/hooks/use-restock-draft.ts @@ -0,0 +1,75 @@ +import { router } from '@inertiajs/react'; +import { useEffect, useRef } from 'react'; +import { + clearRestockDraft, + saveRestockDraft + +} from '@/lib/restock-draft'; +import type {RestockDraftData} from '@/lib/restock-draft'; + +type DraftType = 'create' | 'edit'; + +export function useRestockDraftSave( + type: DraftType, + data: RestockDraftData, + userId?: number, + delay = 500, +) { + const timeoutRef = useRef | null>(null); + const dataRef = useRef(data); + const submittedRef = useRef(false); + + useEffect(() => { + dataRef.current = data; + }, [data]); + + useEffect(() => { + const offBefore = router.on('before', (event) => { + if (event.detail.visit.method !== 'get') { + submittedRef.current = true; + } + }); + const offError = router.on('error', () => { + submittedRef.current = false; + }); + + return () => { + offBefore(); + offError(); + }; + }, []); + + useEffect(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + if (!submittedRef.current) { + saveRestockDraft(type, dataRef.current, userId); + } + + timeoutRef.current = null; + }, delay); + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [data, type, userId, delay]); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + if (submittedRef.current) { + clearRestockDraft(type, userId); + } else { + saveRestockDraft(type, dataRef.current, userId); + } + }; + }, [type, userId]); +} diff --git a/resources/js/lib/restock-draft.ts b/resources/js/lib/restock-draft.ts new file mode 100644 index 0000000..10a7ad3 --- /dev/null +++ b/resources/js/lib/restock-draft.ts @@ -0,0 +1,67 @@ +const DRAFT_PREFIX = 'restock-draft'; + +export type RestockDraftData = { + stockType: 'good' | 'reject'; + selectedProductId: string; + quantities: Record; + notes: string; + photo?: string; +}; + +function getKey(type: 'create' | 'edit', userId?: number): string { + if (type === 'edit') { + return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}`; + } + + return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`; +} + +export function saveRestockDraft( + type: 'create' | 'edit', + data: RestockDraftData, + userId?: number, +): boolean { + if (type !== 'create') { + return false; + } + + try { + const key = getKey(type, userId); + localStorage.setItem(key, JSON.stringify(data)); + + return true; + } catch { + return false; + } +} + +export function loadRestockDraft( + type: 'create' | 'edit', + userId?: number, +): RestockDraftData | null { + try { + const key = getKey(type, userId); + const raw = localStorage.getItem(key); + + if (!raw) { + return null; + } + + return JSON.parse(raw) as RestockDraftData; + + } catch { + return null; + } +} + +export function clearRestockDraft( + type: 'create' | 'edit', + userId?: number, +): void { + try { + const key = getKey(type, userId); + localStorage.removeItem(key); + } catch { + // ignore + } +} diff --git a/resources/js/pages/admin/manage/restock/columns.tsx b/resources/js/pages/admin/manage/restock/columns.tsx new file mode 100644 index 0000000..80b4a90 --- /dev/null +++ b/resources/js/pages/admin/manage/restock/columns.tsx @@ -0,0 +1,67 @@ +export type RestockStockType = 'good' | 'reject'; + +export type RestockItem = { + id: number; + product_variant_id: number; + quantity: number; + unit_price: number; + subtotal: number; + product_variant: { + id: number; + name: string; + photo_url: string | null; + product: { + id: number; + name: string; + }; + }; +}; + +export type Restock = { + id: number; + created_by_id: number; + subtotal: number; + total: number; + notes: string | null; + stock_type: RestockStockType; + created_at: string; + created_by: { + id: number; + user_profile: { + full_name: string; + }; + }; + restock_items: RestockItem[]; +}; + +export type RestockForEdit = { + id: number; + stock_type: RestockStockType; + notes: string | null; + photo_key: string | null; + photo_url: string | null; + items: { + id: number; + product_variant_id: number; + quantity: number; + unit_price: number; + }[]; +}; + +export type ProductForRestock = { + id: number; + name: string; + status: string; + product_variants: { + id: number; + name: string; + stock: number; + reject_stock: number; + photo_url: string | null; + capital_price: number; + }[]; +}; + +export type RestockCreateData = { + products: ProductForRestock[]; +}; diff --git a/resources/js/pages/admin/manage/restock/create.tsx b/resources/js/pages/admin/manage/restock/create.tsx new file mode 100644 index 0000000..6f569df --- /dev/null +++ b/resources/js/pages/admin/manage/restock/create.tsx @@ -0,0 +1,707 @@ +'use no memo'; + +import { Form, Head, usePage } from '@inertiajs/react'; +import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { FileUpload } from '@/components/file-upload'; +import { ImagePreviewModal } from '@/components/image-preview-modal'; +import InputError from '@/components/input-error'; +import { NumberInput } from '@/components/number-input'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from '@/components/ui/combobox'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { Textarea } from '@/components/ui/textarea'; +import { useRestockDraftSave } from '@/hooks/use-restock-draft'; +import { loadRestockDraft } from '@/lib/restock-draft'; +import { getTemporaryUrl } from '@/lib/upload'; +import { formatCurrency } from '@/lib/utils'; +import { index as restockIndex, store } from '@/routes/admin/manage/restocks'; +import type { ProductForRestock, RestockCreateData } from './columns'; + +type CartLine = { + key: string; + photoUrl: string | null; + title: string; + subtitle: string; + price: number; + quantity: number; + onAdjust: (delta: number) => void; + onSet: (value: number) => void; + onRemove: () => void; +}; + +type Props = { + data: RestockCreateData; +}; + +export default function RestockCreate({ data }: Props) { + const { products } = data; + const { auth } = usePage().props as { auth: { user?: { id?: number } } }; + const userId = auth.user?.id; + + const draft = loadRestockDraft('create', userId); + + const [stockType, setStockType] = useState<'good' | 'reject'>( + draft?.stockType === 'reject' ? 'reject' : 'good', + ); + const [selectedProductId, setSelectedProductId] = useState( + draft?.selectedProductId ?? '', + ); + const [quantities, setQuantities] = useState>(() => + Object.fromEntries( + Object.entries(draft?.quantities ?? {}).map(([id, qty]) => [ + Number(id), + qty, + ]), + ), + ); + const [notes, setNotes] = useState(draft?.notes ?? ''); + const [photo, setPhoto] = useState(draft?.photo ?? null); + const [photoUrl, setPhotoUrl] = useState( + draft?.photo ? getTemporaryUrl(draft.photo) : null, + ); + const [uploading, setUploading] = useState(false); + const [cartOpen, setCartOpen] = useState(false); + const [previewKey, setPreviewKey] = useState(null); + const [cartRemoveKey, setCartRemoveKey] = useState(null); + + const draftData = useMemo( + () => ({ + stockType, + selectedProductId, + quantities: Object.fromEntries( + Object.entries(quantities).map(([id, qty]) => [ + String(id), + qty, + ]), + ), + notes, + photo: photo ?? undefined, + }), + [stockType, selectedProductId, quantities, notes, photo], + ); + + useRestockDraftSave('create', draftData, userId); + + const quantitiesRef = useRef(quantities); + + useEffect(() => { + quantitiesRef.current = quantities; + }, [quantities]); + + const selectedProduct = useMemo( + () => + products.find((p) => String(p.id) === selectedProductId) ?? null, + [products, selectedProductId], + ); + + const variantById = useMemo( + () => + new Map( + products.flatMap((p: ProductForRestock) => + p.product_variants.map((v) => [v.id, v]), + ), + ), + [products], + ); + + const subtotal = Object.entries(quantities).reduce( + (sum, [variantId, quantity]) => { + const variant = variantById.get(Number(variantId)); + + return sum + (variant ? variant.capital_price * quantity : 0); + }, + 0, + ); + + const updateQuantity = useCallback((variantId: number, value: number) => { + setQuantities((prev) => ({ + ...prev, + [variantId]: Math.max(0, value), + })); + }, []); + + const incrementQuantity = useCallback( + (variantId: number, amount: number) => { + setQuantities((prev) => ({ + ...prev, + [variantId]: Math.max(0, (prev[variantId] ?? 0) + amount), + })); + }, + [], + ); + + const cartItems: CartLine[] = (() => { + const lines: CartLine[] = []; + + for (const [variantId, quantity] of Object.entries(quantities)) { + if (quantity <= 0) { + continue; + } + + const id = Number(variantId); + const variant = variantById.get(id); + + if (variant) { + lines.push({ + key: `variant-${id}`, + photoUrl: variant.photo_url, + title: variant.name, + subtitle: `${formatCurrency(variant.capital_price)} / pcs`, + price: variant.capital_price, + quantity, + onAdjust: (delta) => incrementQuantity(id, delta), + onSet: (value) => updateQuantity(id, value), + onRemove: () => updateQuantity(id, 0), + }); + } + } + + return lines; + })(); + + function formatQuantity(value: number): string { + return new Intl.NumberFormat('id-ID', { + maximumFractionDigits: 4, + }).format(value); + } + + function getPayload() { + return { + stock_type: stockType, + items: Object.entries(quantitiesRef.current) + .map(([variantId, quantity]) => ({ + product_variant_id: Number(variantId), + quantity: Number(quantity), + })) + .filter((item) => item.quantity > 0), + notes: notes || null, + photo_key: photo, + }; + } + + return ( + <> + + +
+
+

+ Tambah Restock +

+ +
+ +
({ + ...formData, + ...getPayload(), + })} + > + {({ errors, processing }) => ( +
+
+ + + Pilih Produk + + +
+ + + p.name + } + value={selectedProduct} + onValueChange={(value) => + setSelectedProductId( + value + ? String(value.id) + : '', + ) + } + > + + + + Tidak ada produk + ditemukan. + + + {(p) => ( + + {p.name} + + )} + + + + +
+ + {selectedProduct && ( +
+ {selectedProduct.product_variants.map( + (variant) => { + const currentStock = + stockType === 'good' + ? variant.stock + : variant.reject_stock; + + return ( +
0 + ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' + : 'flex items-center justify-between gap-3 rounded-lg border p-3' + } + > +
+ {variant.photo_url ? ( + { + ) : ( +
+ N/A +
+ )} +
+

+ { + variant.name + } +

+

+ Stok:{' '} + {formatQuantity( + Number( + currentStock, + ), + )}{' '} + pcs + ยท{' '} + { + formatCurrency( + variant.capital_price, + ) + } +

+
+
+
+ + + updateQuantity( + variant.id, + val, + ) + } + /> + +
+
+ ); + }, + )} +
+ )} +
+
+
+ +
+ + + Ringkasan + + +
+ + + setStockType( + value as + | 'good' + | 'reject', + ) + } + className="flex flex-wrap gap-4" + > +
+ + +
+
+ + +
+
+ +
+ +
+
+ + Subtotal + + + {formatCurrency(subtotal)} + +
+
+
+ Total + + {formatCurrency( + subtotal, + )} + +
+
+
+ +
+ +