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.
This commit is contained in:
parent
ab3ec86931
commit
cf0e772159
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
73
app/Http/Controllers/Admin/Manage/RestockController.php
Normal file
73
app/Http/Controllers/Admin/Manage/RestockController.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Manage;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\RestockRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\Restock;
|
||||
use App\Services\Admin\Manage\RestockService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class RestockController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private RestockService $service,
|
||||
) {}
|
||||
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/restock/index', [
|
||||
'restocks' => $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'
|
||||
);
|
||||
}
|
||||
}
|
||||
39
app/Http/Requests/Admin/Manage/RestockRequest.php
Normal file
39
app/Http/Requests/Admin/Manage/RestockRequest.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
|
||||
use App\Enums\ProductStockQuality;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class RestockRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'stock_type' => ['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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
276
app/Services/Admin/Manage/RestockService.php
Normal file
276
app/Services/Admin/Manage/RestockService.php
Normal file
@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Manage;
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use App\Enums\ProductStockQuality;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\Restock;
|
||||
use App\Models\RestockItem;
|
||||
use App\Services\Concerns\RegistersMedia;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\S3PresignedService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RestockService
|
||||
{
|
||||
use RegistersMedia;
|
||||
|
||||
private const QUALITY_STOCK_MAP = [
|
||||
ProductStockQuality::GOOD->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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 },
|
||||
];
|
||||
|
||||
|
||||
75
resources/js/hooks/use-restock-draft.ts
Normal file
75
resources/js/hooks/use-restock-draft.ts
Normal file
@ -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<ReturnType<typeof setTimeout> | 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]);
|
||||
}
|
||||
67
resources/js/lib/restock-draft.ts
Normal file
67
resources/js/lib/restock-draft.ts
Normal file
@ -0,0 +1,67 @@
|
||||
const DRAFT_PREFIX = 'restock-draft';
|
||||
|
||||
export type RestockDraftData = {
|
||||
stockType: 'good' | 'reject';
|
||||
selectedProductId: string;
|
||||
quantities: Record<string, number>;
|
||||
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
|
||||
}
|
||||
}
|
||||
67
resources/js/pages/admin/manage/restock/columns.tsx
Normal file
67
resources/js/pages/admin/manage/restock/columns.tsx
Normal file
@ -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[];
|
||||
};
|
||||
707
resources/js/pages/admin/manage/restock/create.tsx
Normal file
707
resources/js/pages/admin/manage/restock/create.tsx
Normal file
@ -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<Record<number, number>>(() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(draft?.quantities ?? {}).map(([id, qty]) => [
|
||||
Number(id),
|
||||
qty,
|
||||
]),
|
||||
),
|
||||
);
|
||||
const [notes, setNotes] = useState(draft?.notes ?? '');
|
||||
const [photo, setPhoto] = useState<string | null>(draft?.photo ?? null);
|
||||
const [photoUrl, setPhotoUrl] = useState<string | null>(
|
||||
draft?.photo ? getTemporaryUrl(draft.photo) : null,
|
||||
);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [cartOpen, setCartOpen] = useState(false);
|
||||
const [previewKey, setPreviewKey] = useState<string | null>(null);
|
||||
const [cartRemoveKey, setCartRemoveKey] = useState<string | null>(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 (
|
||||
<>
|
||||
<Head title="Tambah Restock" />
|
||||
|
||||
<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">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Tambah Restock
|
||||
</h2>
|
||||
<Button asChild variant="outline">
|
||||
<a href={restockIndex.url()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Kembali
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
action={store()}
|
||||
transform={(formData) => ({
|
||||
...formData,
|
||||
...getPayload(),
|
||||
})}
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pilih Produk</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Nama Produk{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Combobox
|
||||
items={products}
|
||||
itemToStringLabel={(p) =>
|
||||
p.name
|
||||
}
|
||||
value={selectedProduct}
|
||||
onValueChange={(value) =>
|
||||
setSelectedProductId(
|
||||
value
|
||||
? String(value.id)
|
||||
: '',
|
||||
)
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Cari produk..."
|
||||
className="w-full"
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>
|
||||
Tidak ada produk
|
||||
ditemukan.
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(p) => (
|
||||
<ComboboxItem
|
||||
key={p.id}
|
||||
value={p}
|
||||
>
|
||||
{p.name}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<InputError
|
||||
message={errors.items}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedProduct && (
|
||||
<div className="space-y-2">
|
||||
{selectedProduct.product_variants.map(
|
||||
(variant) => {
|
||||
const currentStock =
|
||||
stockType === 'good'
|
||||
? variant.stock
|
||||
: variant.reject_stock;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={variant.id}
|
||||
className={
|
||||
(quantities[
|
||||
variant
|
||||
.id
|
||||
] ??
|
||||
0) > 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'
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{variant.photo_url ? (
|
||||
<img
|
||||
src={
|
||||
variant.photo_url
|
||||
}
|
||||
alt={
|
||||
variant.name
|
||||
}
|
||||
className="h-10 w-10 shrink-0 rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
|
||||
N/A
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">
|
||||
{
|
||||
variant.name
|
||||
}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stok:{' '}
|
||||
{formatQuantity(
|
||||
Number(
|
||||
currentStock,
|
||||
),
|
||||
)}{' '}
|
||||
pcs
|
||||
·{' '}
|
||||
{
|
||||
formatCurrency(
|
||||
variant.capital_price,
|
||||
)
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={
|
||||
!(
|
||||
quantities[
|
||||
variant
|
||||
.id
|
||||
] ??
|
||||
0
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
incrementQuantity(
|
||||
variant.id,
|
||||
-1,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
min={0}
|
||||
className="w-24 text-center"
|
||||
value={
|
||||
quantities[
|
||||
variant
|
||||
.id
|
||||
] ??
|
||||
0
|
||||
}
|
||||
onValueChange={(
|
||||
val,
|
||||
) =>
|
||||
updateQuantity(
|
||||
variant.id,
|
||||
val,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
incrementQuantity(
|
||||
variant.id,
|
||||
1,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 md:col-span-1">
|
||||
<Card className="sticky top-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Ringkasan</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Jenis Stok</Label>
|
||||
<RadioGroup
|
||||
value={stockType}
|
||||
onValueChange={(value) =>
|
||||
setStockType(
|
||||
value as
|
||||
| 'good'
|
||||
| 'reject',
|
||||
)
|
||||
}
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="good"
|
||||
id="stock-type-good"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="stock-type-good"
|
||||
className="font-normal"
|
||||
>
|
||||
Bagus
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="reject"
|
||||
id="stock-type-reject"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="stock-type-reject"
|
||||
className="font-normal"
|
||||
>
|
||||
Reject
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<InputError
|
||||
message={errors.stock_type}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Subtotal
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t pt-2">
|
||||
<div className="flex items-center justify-between text-sm font-semibold">
|
||||
<span>Total</span>
|
||||
<span>
|
||||
{formatCurrency(
|
||||
subtotal,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="notes">
|
||||
Keterangan
|
||||
</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={notes}
|
||||
onChange={(e) =>
|
||||
setNotes(e.target.value)
|
||||
}
|
||||
placeholder="Masukkan keterangan"
|
||||
maxLength={100}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.notes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Foto</Label>
|
||||
<FileUpload
|
||||
value={photo}
|
||||
onChange={(key) => {
|
||||
setPhoto(key);
|
||||
setPhotoUrl(
|
||||
key
|
||||
? getTemporaryUrl(
|
||||
key,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}}
|
||||
folder="restock"
|
||||
existingUrl={photoUrl}
|
||||
onUploadingChange={
|
||||
setUploading
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.photo_key}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={
|
||||
processing ||
|
||||
uploading ||
|
||||
!selectedProductId ||
|
||||
Object.values(
|
||||
quantities,
|
||||
).every((q) => q <= 0)
|
||||
}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setCartOpen(true)}
|
||||
className="fixed top-1/2 right-4 z-50 h-14 w-14 -translate-y-1/2 rounded-full shadow-lg"
|
||||
size="icon"
|
||||
aria-label="Buka keranjang restock"
|
||||
>
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
{cartItems.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs font-semibold text-white">
|
||||
{cartItems.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Sheet open={cartOpen} onOpenChange={setCartOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Keranjang Restock</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-6 pb-6">
|
||||
{cartItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Keranjang kosong.
|
||||
</p>
|
||||
) : (
|
||||
cartItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="space-y-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{item.photoUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPreviewKey(
|
||||
item.key,
|
||||
)
|
||||
}
|
||||
className="block h-10 w-10 shrink-0 overflow-hidden rounded-md border transition-opacity hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
src={item.photoUrl}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
|
||||
N/A
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() =>
|
||||
setCartRemoveKey(item.key)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={
|
||||
item.quantity <= 0
|
||||
}
|
||||
onClick={() =>
|
||||
item.onAdjust(-1)
|
||||
}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
min={0}
|
||||
className="w-20 text-center"
|
||||
value={item.quantity}
|
||||
onValueChange={item.onSet}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() =>
|
||||
item.onAdjust(1)
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(
|
||||
item.price *
|
||||
item.quantity,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<span className="text-sm">Subtotal</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{formatCurrency(subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<ImagePreviewModal
|
||||
open={previewKey !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPreviewKey(null);
|
||||
}
|
||||
}}
|
||||
src={
|
||||
cartItems.find((i) => i.key === previewKey)?.photoUrl ??
|
||||
null
|
||||
}
|
||||
title={cartItems.find((i) => i.key === previewKey)?.title}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={cartRemoveKey !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCartRemoveKey(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Item Keranjang"
|
||||
description="Apakah Anda yakin ingin menghapus item ini dari keranjang?"
|
||||
confirmLabel="Hapus"
|
||||
variant="destructive"
|
||||
onConfirm={() => {
|
||||
cartItems
|
||||
.find((i) => i.key === cartRemoveKey)
|
||||
?.onRemove();
|
||||
setCartRemoveKey(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
644
resources/js/pages/admin/manage/restock/edit.tsx
Normal file
644
resources/js/pages/admin/manage/restock/edit.tsx
Normal file
@ -0,0 +1,644 @@
|
||||
'use no memo';
|
||||
|
||||
import { Form, Head } 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 { 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 { getTemporaryUrl } from '@/lib/upload';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import {
|
||||
index as restockIndex,
|
||||
update,
|
||||
} from '@/routes/admin/manage/restocks';
|
||||
import type { RestockCreateData, RestockForEdit } 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 = {
|
||||
restock: RestockForEdit;
|
||||
data: RestockCreateData;
|
||||
};
|
||||
|
||||
export default function RestockEdit({ restock, data }: Props) {
|
||||
const { products } = data;
|
||||
|
||||
const [stockType, setStockType] = useState<'good' | 'reject'>(
|
||||
restock.stock_type === 'reject' ? 'reject' : 'good',
|
||||
);
|
||||
const selectedProductId = useMemo(() => {
|
||||
const items = restock.items ?? [];
|
||||
const product = products.find((p) =>
|
||||
p.product_variants.some((v) =>
|
||||
items.some((i) => i.product_variant_id === v.id),
|
||||
),
|
||||
);
|
||||
|
||||
return product ? String(product.id) : '';
|
||||
}, [products, restock.items]);
|
||||
const [quantities, setQuantities] = useState<Record<number, number>>(() =>
|
||||
Object.fromEntries(
|
||||
(restock.items ?? []).map((item) => [
|
||||
item.product_variant_id,
|
||||
item.quantity,
|
||||
]),
|
||||
),
|
||||
);
|
||||
const [notes, setNotes] = useState(restock.notes ?? '');
|
||||
const [photo, setPhoto] = useState<string | null>(restock.photo_key);
|
||||
const [photoUrl, setPhotoUrl] = useState<string | null>(
|
||||
restock.photo_url,
|
||||
);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [cartOpen, setCartOpen] = useState(false);
|
||||
const [previewKey, setPreviewKey] = useState<string | null>(null);
|
||||
const [cartRemoveKey, setCartRemoveKey] = useState<string | null>(null);
|
||||
|
||||
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) =>
|
||||
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 (
|
||||
<>
|
||||
<Head title="Edit Restock" />
|
||||
|
||||
<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">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Edit Restock
|
||||
</h2>
|
||||
<Button asChild variant="outline">
|
||||
<a href={restockIndex.url()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Kembali
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
action={update(restock.id)}
|
||||
method="put"
|
||||
transform={(formData) => ({
|
||||
...formData,
|
||||
...getPayload(),
|
||||
})}
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Item Restock</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedProduct ? (
|
||||
<div className="space-y-2">
|
||||
{selectedProduct.product_variants.map(
|
||||
(variant) => {
|
||||
const currentStock =
|
||||
stockType === 'good'
|
||||
? variant.stock
|
||||
: variant.reject_stock;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={variant.id}
|
||||
className={
|
||||
(quantities[
|
||||
variant
|
||||
.id
|
||||
] ??
|
||||
0) > 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'
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{variant.photo_url ? (
|
||||
<img
|
||||
src={
|
||||
variant.photo_url
|
||||
}
|
||||
alt={
|
||||
variant.name
|
||||
}
|
||||
className="h-10 w-10 shrink-0 rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
|
||||
N/A
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">
|
||||
{
|
||||
variant.name
|
||||
}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stok:{' '}
|
||||
{formatQuantity(
|
||||
Number(
|
||||
currentStock,
|
||||
),
|
||||
)}{' '}
|
||||
pcs
|
||||
·{' '}
|
||||
{
|
||||
formatCurrency(
|
||||
variant.capital_price,
|
||||
)
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={
|
||||
!(
|
||||
quantities[
|
||||
variant
|
||||
.id
|
||||
] ??
|
||||
0
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
incrementQuantity(
|
||||
variant.id,
|
||||
-1,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
min={0}
|
||||
className="w-24 text-center"
|
||||
value={
|
||||
quantities[
|
||||
variant
|
||||
.id
|
||||
] ??
|
||||
0
|
||||
}
|
||||
onValueChange={(
|
||||
val,
|
||||
) =>
|
||||
updateQuantity(
|
||||
variant.id,
|
||||
val,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
incrementQuantity(
|
||||
variant.id,
|
||||
1,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tidak ada item.
|
||||
</p>
|
||||
)}
|
||||
<InputError message={errors.items} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 md:col-span-1">
|
||||
<Card className="sticky top-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Ringkasan</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Jenis Stok</Label>
|
||||
<RadioGroup
|
||||
value={stockType}
|
||||
onValueChange={(value) =>
|
||||
setStockType(
|
||||
value as
|
||||
| 'good'
|
||||
| 'reject',
|
||||
)
|
||||
}
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="good"
|
||||
id="edit-stock-type-good"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="edit-stock-type-good"
|
||||
className="font-normal"
|
||||
>
|
||||
Bagus
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="reject"
|
||||
id="edit-stock-type-reject"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="edit-stock-type-reject"
|
||||
className="font-normal"
|
||||
>
|
||||
Reject
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<InputError
|
||||
message={errors.stock_type}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Subtotal
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t pt-2">
|
||||
<div className="flex items-center justify-between text-sm font-semibold">
|
||||
<span>Total</span>
|
||||
<span>
|
||||
{formatCurrency(
|
||||
subtotal,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-notes">
|
||||
Keterangan
|
||||
</Label>
|
||||
<Textarea
|
||||
id="edit-notes"
|
||||
value={notes}
|
||||
onChange={(e) =>
|
||||
setNotes(e.target.value)
|
||||
}
|
||||
placeholder="Masukkan keterangan"
|
||||
maxLength={100}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.notes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Foto</Label>
|
||||
<FileUpload
|
||||
value={photo}
|
||||
onChange={(key) => {
|
||||
setPhoto(key);
|
||||
setPhotoUrl(
|
||||
key
|
||||
? getTemporaryUrl(
|
||||
key,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}}
|
||||
folder="restock"
|
||||
existingUrl={photoUrl}
|
||||
onUploadingChange={
|
||||
setUploading
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.photo_key}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={
|
||||
processing ||
|
||||
uploading ||
|
||||
Object.values(
|
||||
quantities,
|
||||
).every((q) => q <= 0)
|
||||
}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setCartOpen(true)}
|
||||
className="fixed top-1/2 right-4 z-50 h-14 w-14 -translate-y-1/2 rounded-full shadow-lg"
|
||||
size="icon"
|
||||
aria-label="Buka keranjang restock"
|
||||
>
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
{cartItems.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs font-semibold text-white">
|
||||
{cartItems.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Sheet open={cartOpen} onOpenChange={setCartOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Keranjang Restock</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-6 pb-6">
|
||||
{cartItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Keranjang kosong.
|
||||
</p>
|
||||
) : (
|
||||
cartItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="space-y-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{item.photoUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPreviewKey(
|
||||
item.key,
|
||||
)
|
||||
}
|
||||
className="block h-10 w-10 shrink-0 overflow-hidden rounded-md border transition-opacity hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
src={item.photoUrl}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
|
||||
N/A
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() =>
|
||||
setCartRemoveKey(item.key)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={
|
||||
item.quantity <= 0
|
||||
}
|
||||
onClick={() =>
|
||||
item.onAdjust(-1)
|
||||
}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
min={0}
|
||||
className="w-20 text-center"
|
||||
value={item.quantity}
|
||||
onValueChange={item.onSet}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() =>
|
||||
item.onAdjust(1)
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(
|
||||
item.price *
|
||||
item.quantity,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<span className="text-sm">Subtotal</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{formatCurrency(subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<ImagePreviewModal
|
||||
open={previewKey !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPreviewKey(null);
|
||||
}
|
||||
}}
|
||||
src={
|
||||
cartItems.find((i) => i.key === previewKey)?.photoUrl ??
|
||||
null
|
||||
}
|
||||
title={cartItems.find((i) => i.key === previewKey)?.title}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={cartRemoveKey !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCartRemoveKey(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Item Keranjang"
|
||||
description="Apakah Anda yakin ingin menghapus item ini dari keranjang?"
|
||||
confirmLabel="Hapus"
|
||||
variant="destructive"
|
||||
onConfirm={() => {
|
||||
cartItems
|
||||
.find((i) => i.key === cartRemoveKey)
|
||||
?.onRemove();
|
||||
setCartRemoveKey(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
162
resources/js/pages/admin/manage/restock/index.tsx
Normal file
162
resources/js/pages/admin/manage/restock/index.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { CardTable } from '@/components/card-table';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
destroy,
|
||||
create as restockCreate,
|
||||
index as restockIndex,
|
||||
edit as restockEdit,
|
||||
} from '@/routes/admin/manage/restocks';
|
||||
import type { Restock } from './columns';
|
||||
import { RestockCardRow } from './restock-card';
|
||||
import { RestockItemSubRow } from './restock-sub-row';
|
||||
|
||||
type Props = {
|
||||
restocks: {
|
||||
data: Restock[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
|
||||
export default function RestockIndex({ restocks }: Props) {
|
||||
const [deleting, setDeleting] = useState<Restock | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const expand = useCardTableExpand(true);
|
||||
|
||||
const pagination = {
|
||||
current_page: restocks.current_page,
|
||||
last_page: restocks.last_page,
|
||||
per_page: restocks.per_page,
|
||||
total: restocks.total,
|
||||
};
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(
|
||||
restockIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(
|
||||
restockIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
restockIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page],
|
||||
);
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.delete(destroy.url(deleting.id), {
|
||||
onSuccess: () => setDeleting(null),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Restock" />
|
||||
|
||||
<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">
|
||||
Restock
|
||||
</h2>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<a href={restockCreate.url()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CardTable
|
||||
data={restocks.data}
|
||||
getItemKey={(r) => r.id}
|
||||
expandedKeys={expand.expandedKeys}
|
||||
onToggleExpand={expand.toggleExpand}
|
||||
searchValue={search}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="Cari berdasarkan produk..."
|
||||
pagination={pagination}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
renderCard={({
|
||||
item,
|
||||
index,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
}) => (
|
||||
<RestockCardRow
|
||||
restock={item}
|
||||
index={
|
||||
(pagination.current_page - 1) *
|
||||
pagination.per_page +
|
||||
index +
|
||||
1
|
||||
}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onEdit={(r) => {
|
||||
window.location.href = restockEdit.url(r.id);
|
||||
}}
|
||||
onDelete={(r) => setDeleting(r)}
|
||||
/>
|
||||
)}
|
||||
renderSubContent={(restock) => (
|
||||
<RestockItemSubRow restock={restock} />
|
||||
)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Restock"
|
||||
description="Apakah Anda yakin ingin menghapus restock ini? Stok akan dikembalikan. Tindakan ini tidak dapat dibatalkan."
|
||||
confirmLabel="Hapus"
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
197
resources/js/pages/admin/manage/restock/restock-card.tsx
Normal file
197
resources/js/pages/admin/manage/restock/restock-card.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { Restock, RestockStockType } from './columns';
|
||||
|
||||
function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
const STOCK_TYPE_CONFIG: Record<
|
||||
RestockStockType,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
good: {
|
||||
label: 'Bagus',
|
||||
className: 'bg-green-100 text-green-800 hover:bg-green-100',
|
||||
},
|
||||
reject: {
|
||||
label: 'Reject',
|
||||
className: 'bg-red-100 text-red-800 hover:bg-red-100',
|
||||
},
|
||||
};
|
||||
|
||||
export type RestockCardRowParams = {
|
||||
restock: Restock;
|
||||
index: number;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onEdit: (restock: Restock) => void;
|
||||
onDelete: (restock: Restock) => void;
|
||||
};
|
||||
|
||||
export function RestockCardRow({
|
||||
restock,
|
||||
index,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: RestockCardRowParams) {
|
||||
const items = restock.restock_items ?? [];
|
||||
const variantCount = items.length;
|
||||
const productNames = [
|
||||
...new Set(
|
||||
items.map((item) => item.product_variant?.product?.name).filter(Boolean),
|
||||
),
|
||||
];
|
||||
const totalQty = items.reduce(
|
||||
(sum, item) => sum + Number(item.quantity),
|
||||
0,
|
||||
);
|
||||
const stockTypeConfig =
|
||||
STOCK_TYPE_CONFIG[restock.stock_type] ?? STOCK_TYPE_CONFIG.good;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 h-6 w-6 shrink-0"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{index}.
|
||||
</span>
|
||||
<h3 className="truncate font-medium">
|
||||
{productNames.length > 0
|
||||
? productNames.join(', ')
|
||||
: '-'}
|
||||
</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={stockTypeConfig.className}
|
||||
>
|
||||
{stockTypeConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{variantCount > 0 && (
|
||||
<span>({variantCount} varian)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
|
||||
{formatDateTime(restock.created_at)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Oleh:{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{restock.created_by?.user_profile
|
||||
?.full_name ?? '-'}
|
||||
</span>
|
||||
</span>
|
||||
{restock.notes && (
|
||||
<span className="max-w-[200px] truncate">
|
||||
{restock.notes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
|
||||
<span>
|
||||
<span className="text-muted-foreground">
|
||||
Qty:{' '}
|
||||
</span>
|
||||
{formatNumber(totalQty)}
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-muted-foreground">
|
||||
Sub:{' '}
|
||||
</span>
|
||||
{formatCurrency(restock.subtotal)}
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
<span className="text-muted-foreground font-normal">
|
||||
Total:{' '}
|
||||
</span>
|
||||
{formatCurrency(restock.total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEdit(restock)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(restock)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
124
resources/js/pages/admin/manage/restock/restock-sub-row.tsx
Normal file
124
resources/js/pages/admin/manage/restock/restock-sub-row.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import { useState } from 'react';
|
||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { Restock } from './columns';
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
function VariantPhotoPreview({ url, title }: { url: string; title: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="block h-10 w-10 overflow-hidden rounded-md border transition-opacity hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
<ImagePreviewModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
src={url}
|
||||
title={title}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RestockItemSubRow({ restock }: { restock: Restock }) {
|
||||
const items = restock.restock_items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">
|
||||
No
|
||||
</TableHead>
|
||||
<TableHead className="w-[60px]">Foto</TableHead>
|
||||
<TableHead>Produk</TableHead>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead className="text-right">
|
||||
Harga Modal
|
||||
</TableHead>
|
||||
<TableHead className="text-center">Qty</TableHead>
|
||||
<TableHead className="text-right">Subtotal</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
Tidak ada item.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.product_variant?.photo_url ? (
|
||||
<VariantPhotoPreview
|
||||
url={item.product_variant.photo_url}
|
||||
title={item.product_variant.name}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
|
||||
N/A
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.product_variant?.product?.name ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.product_variant?.name ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(item.unit_price)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{formatNumber(item.quantity)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatCurrency(item.subtotal)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Http\Controllers\Admin\HR\EmployeeController;
|
||||
use App\Http\Controllers\Admin\HR\LeaveRequestController;
|
||||
use App\Http\Controllers\Admin\Manage\PurchaseController;
|
||||
use App\Http\Controllers\Admin\Manage\RestockController;
|
||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||
use App\Http\Controllers\Admin\Master\CustomerController;
|
||||
use App\Http\Controllers\Admin\Master\Product\ProductController;
|
||||
@ -112,6 +113,7 @@
|
||||
|
||||
Route::prefix('admin/manage')->name('admin.manage.')->group(function () {
|
||||
Route::resource('purchases', PurchaseController::class)->except(['show'])->middleware('permission:purchase.view|purchase.create|purchase.update|purchase.delete');
|
||||
Route::resource('restocks', RestockController::class)->except(['show'])->middleware('permission:restock.view|restock.create|restock.update|restock.delete');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
838
tests/Feature/Admin/Manage/RestockTest.php
Normal file
838
tests/Feature/Admin/Manage/RestockTest.php
Normal file
@ -0,0 +1,838 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use App\Enums\ProductStockQuality;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\Restock;
|
||||
use App\Models\RestockItem;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HELPERS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function giveRestockPermissions(User $user): void
|
||||
{
|
||||
$seeder = new RolePermissionSeeder;
|
||||
$seeder->run();
|
||||
|
||||
$user->givePermissionTo([
|
||||
'restock.view',
|
||||
'restock.create',
|
||||
'restock.update',
|
||||
'restock.delete',
|
||||
]);
|
||||
}
|
||||
|
||||
function makeRestockVariant(array $overrides = []): ProductVariant
|
||||
{
|
||||
$variant = ProductVariant::factory()->create(array_merge([
|
||||
'stock' => 10,
|
||||
'reject_stock' => 5,
|
||||
], $overrides));
|
||||
|
||||
$variant->productPrices()->create([
|
||||
'type' => PriceType::CAPITAL,
|
||||
'price' => 50000,
|
||||
]);
|
||||
|
||||
return $variant;
|
||||
}
|
||||
|
||||
function makeValidRestockPayload(array $overrides = []): array
|
||||
{
|
||||
$variant = makeRestockVariant();
|
||||
|
||||
return array_merge([
|
||||
'stock_type' => 'good',
|
||||
'items' => [
|
||||
['product_variant_id' => $variant->id, 'quantity' => 10],
|
||||
],
|
||||
'notes' => 'Restock test',
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| AUTHENTICATION
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('guests are redirected to the login page', function () {
|
||||
$response = $this->get(route('admin.manage.restocks.index'));
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('guests are redirected when visiting create page', function () {
|
||||
$response = $this->get(route('admin.manage.restocks.create'));
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('guests are redirected when visiting edit page', function () {
|
||||
$restock = Restock::factory()->create();
|
||||
$response = $this->get(route('admin.manage.restocks.edit', $restock));
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('guest cannot create restock', function () {
|
||||
$response = $this->post(route('admin.manage.restocks.store'), makeValidRestockPayload());
|
||||
$response->assertRedirect(route('login'));
|
||||
$this->assertDatabaseCount('restocks', 0);
|
||||
});
|
||||
|
||||
test('guest cannot update restock', function () {
|
||||
$restock = Restock::factory()->create();
|
||||
$response = $this->put(route('admin.manage.restocks.update', $restock), makeValidRestockPayload());
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('guest cannot delete restock', function () {
|
||||
$restock = Restock::factory()->create();
|
||||
$response = $this->delete(route('admin.manage.restocks.destroy', $restock));
|
||||
$response->assertRedirect(route('login'));
|
||||
$this->assertDatabaseHas('restocks', ['id' => $restock->id, 'deleted_at' => null]);
|
||||
});
|
||||
|
||||
test('authenticated users can visit the restock index page', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.manage.restocks.index'));
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('authenticated users can visit the restock create page', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.manage.restocks.create'));
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('authenticated users can visit the restock edit page', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$restock = Restock::factory()->create();
|
||||
|
||||
$response = $this->get(route('admin.manage.restocks.edit', $restock));
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| INDEX PAGE
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('restock index page displays restocks', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
Restock::factory()->count(3)->create();
|
||||
|
||||
$response = $this->get(route('admin.manage.restocks.index'));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/manage/restock/index')
|
||||
->has('restocks.data', 3)
|
||||
->where('restocks.total', 3)
|
||||
->where('restocks.current_page', 1)
|
||||
->where('restocks.per_page', 25)
|
||||
);
|
||||
});
|
||||
|
||||
test('index page works with zero restocks', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.manage.restocks.index'));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/manage/restock/index')
|
||||
->has('restocks.data', 0)
|
||||
->where('restocks.total', 0)
|
||||
);
|
||||
});
|
||||
|
||||
test('index page does not display soft-deleted restocks', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
Restock::factory()->create(['notes' => 'Active Restock']);
|
||||
Restock::factory()->create(['notes' => 'Deleted Restock'])->delete();
|
||||
|
||||
$response = $this->get(route('admin.manage.restocks.index'));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/manage/restock/index')
|
||||
->has('restocks.data', 1)
|
||||
->where('restocks.total', 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('index page includes restock items with nested data', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), makeValidRestockPayload());
|
||||
|
||||
$response = $this->get(route('admin.manage.restocks.index'));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->has('restocks.data.0.restock_items', 1)
|
||||
->has('restocks.data.0.restock_items.0.product_variant')
|
||||
->has('restocks.data.0.restock_items.0.product_variant.product')
|
||||
);
|
||||
});
|
||||
|
||||
test('index page returns correct restock item data', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$variant = makeRestockVariant(['name' => 'Hitam M']);
|
||||
$this->post(route('admin.manage.restocks.store'), [
|
||||
'stock_type' => 'good',
|
||||
'items' => [
|
||||
['product_variant_id' => $variant->id, 'quantity' => 10],
|
||||
],
|
||||
'notes' => 'Restock test',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('admin.manage.restocks.index'));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->where('restocks.data.0.stock_type', 'good')
|
||||
->where('restocks.data.0.restock_items.0.quantity', 10)
|
||||
->where('restocks.data.0.restock_items.0.unit_price', 50000)
|
||||
->where('restocks.data.0.restock_items.0.subtotal', 500000)
|
||||
->where('restocks.data.0.restock_items.0.product_variant.name', 'Hitam M')
|
||||
->where('restocks.data.0.restock_items.0.product_variant.product.name', $variant->product->name)
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| CREATE / STORE - BASIC
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('restock can be created', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.restocks.store'), makeValidRestockPayload());
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('admin.manage.restocks.index'));
|
||||
|
||||
$this->assertDatabaseCount('restocks', 1);
|
||||
$this->assertDatabaseCount('restock_items', 1);
|
||||
});
|
||||
|
||||
test('restock increases product variant stock for good quality', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$variant = makeRestockVariant(['stock' => 10, 'reject_stock' => 5]);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), [
|
||||
'stock_type' => 'good',
|
||||
'items' => [
|
||||
['product_variant_id' => $variant->id, 'quantity' => 7],
|
||||
],
|
||||
'notes' => null,
|
||||
]);
|
||||
|
||||
$variant->refresh();
|
||||
expect($variant->stock)->toBe(17);
|
||||
expect($variant->reject_stock)->toBe(5);
|
||||
});
|
||||
|
||||
test('restock increases product variant reject stock for reject quality', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$variant = makeRestockVariant(['stock' => 10, 'reject_stock' => 5]);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), [
|
||||
'stock_type' => 'reject',
|
||||
'items' => [
|
||||
['product_variant_id' => $variant->id, 'quantity' => 7],
|
||||
],
|
||||
'notes' => null,
|
||||
]);
|
||||
|
||||
$variant->refresh();
|
||||
expect($variant->stock)->toBe(10);
|
||||
expect($variant->reject_stock)->toBe(12);
|
||||
});
|
||||
|
||||
test('restock uses capital price for unit price and subtotal', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$variant = makeRestockVariant();
|
||||
$variant->productPrices()->updateOrCreate(
|
||||
['type' => PriceType::CAPITAL],
|
||||
['price' => 60000],
|
||||
);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), [
|
||||
'stock_type' => 'good',
|
||||
'items' => [
|
||||
['product_variant_id' => $variant->id, 'quantity' => 10],
|
||||
],
|
||||
'notes' => null,
|
||||
]);
|
||||
|
||||
$restock = Restock::first();
|
||||
expect($restock->subtotal)->toBe(600000);
|
||||
expect($restock->total)->toBe(600000);
|
||||
|
||||
$item = RestockItem::first();
|
||||
expect($item->unit_price)->toBe(60000);
|
||||
expect($item->subtotal)->toBe(600000);
|
||||
});
|
||||
|
||||
test('restock calculates subtotal across multiple items', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$variantA = makeRestockVariant();
|
||||
$variantB = makeRestockVariant();
|
||||
$variantB->productPrices()->updateOrCreate(
|
||||
['type' => PriceType::CAPITAL],
|
||||
['price' => 70000],
|
||||
);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), [
|
||||
'stock_type' => 'good',
|
||||
'items' => [
|
||||
['product_variant_id' => $variantA->id, 'quantity' => 10],
|
||||
['product_variant_id' => $variantB->id, 'quantity' => 5],
|
||||
],
|
||||
'notes' => null,
|
||||
]);
|
||||
|
||||
$restock = Restock::first();
|
||||
expect($restock->subtotal)->toBe(850000); // (50000*10) + (70000*5)
|
||||
expect($restock->total)->toBe(850000);
|
||||
});
|
||||
|
||||
test('restock sets created_by_id to current user', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), makeValidRestockPayload());
|
||||
|
||||
$restock = Restock::first();
|
||||
expect($restock->created_by_id)->toBe($user->id);
|
||||
});
|
||||
|
||||
test('restock can be created with notes', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), makeValidRestockPayload([
|
||||
'notes' => 'Restock tambahan untuk toko',
|
||||
]));
|
||||
|
||||
$restock = Restock::first();
|
||||
expect($restock->notes)->toBe('Restock tambahan untuk toko');
|
||||
});
|
||||
|
||||
test('restock can be created without notes', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), makeValidRestockPayload([
|
||||
'notes' => null,
|
||||
]));
|
||||
|
||||
$restock = Restock::first();
|
||||
expect($restock->notes)->toBeNull();
|
||||
});
|
||||
|
||||
test('restock creates items with correct relationships', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), makeValidRestockPayload());
|
||||
|
||||
$restock = Restock::first();
|
||||
$item = RestockItem::first();
|
||||
|
||||
expect($item->restock_id)->toBe($restock->id);
|
||||
expect($item->user_id)->toBe($user->id);
|
||||
expect($item->product_variant_id)->not->toBeNull();
|
||||
expect($item->quantity)->toBe(10);
|
||||
expect($item->unit_price)->toBe(50000);
|
||||
expect($item->subtotal)->toBe(500000);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| STORE VALIDATION
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('restock items are required', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.restocks.store'), makeValidRestockPayload([
|
||||
'items' => [],
|
||||
]));
|
||||
$response->assertSessionHasErrors('items');
|
||||
});
|
||||
|
||||
test('restock item quantity must be at least 1', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.restocks.store'), makeValidRestockPayload([
|
||||
'items' => [
|
||||
['product_variant_id' => ProductVariant::factory()->create()->id, 'quantity' => 0],
|
||||
],
|
||||
]));
|
||||
$response->assertSessionHasErrors('items.0.quantity');
|
||||
});
|
||||
|
||||
test('restock item product variant must exist', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.restocks.store'), makeValidRestockPayload([
|
||||
'items' => [
|
||||
['product_variant_id' => 999999, 'quantity' => 5],
|
||||
],
|
||||
]));
|
||||
$response->assertSessionHasErrors('items.0.product_variant_id');
|
||||
});
|
||||
|
||||
test('restock stock_type must be valid', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.restocks.store'), makeValidRestockPayload([
|
||||
'stock_type' => 'invalid',
|
||||
]));
|
||||
$response->assertSessionHasErrors('stock_type');
|
||||
});
|
||||
|
||||
test('restock notes must not exceed 100 characters', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.restocks.store'), makeValidRestockPayload([
|
||||
'notes' => str_repeat('a', 101),
|
||||
]));
|
||||
$response->assertSessionHasErrors('notes');
|
||||
});
|
||||
|
||||
test('restock photo_key must not exceed 500 characters', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.restocks.store'), makeValidRestockPayload([
|
||||
'photo_key' => str_repeat('a', 501),
|
||||
]));
|
||||
$response->assertSessionHasErrors('photo_key');
|
||||
});
|
||||
|
||||
test('restock photo is stored on create', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), makeValidRestockPayload([
|
||||
'photo_key' => 'restock/test-photo.jpg',
|
||||
]));
|
||||
|
||||
$restock = Restock::first();
|
||||
expect($restock->getFirstMedia('photos')?->file_name)
|
||||
->toBe('restock/test-photo.jpg');
|
||||
});
|
||||
|
||||
test('restock photo is returned on edit page', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), makeValidRestockPayload([
|
||||
'photo_key' => 'restock/test-photo.jpg',
|
||||
]));
|
||||
|
||||
$restock = Restock::first();
|
||||
|
||||
$response = $this->get(route('admin.manage.restocks.edit', $restock));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->where('restock.photo_key', 'restock/test-photo.jpg')
|
||||
);
|
||||
});
|
||||
|
||||
test('restock photo is replaced on update', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), makeValidRestockPayload([
|
||||
'photo_key' => 'restock/old-photo.jpg',
|
||||
]));
|
||||
|
||||
$restock = Restock::first();
|
||||
|
||||
$this->put(route('admin.manage.restocks.update', $restock), makeValidRestockPayload([
|
||||
'photo_key' => 'restock/new-photo.jpg',
|
||||
]));
|
||||
|
||||
$media = $restock->fresh()->getFirstMedia('photos');
|
||||
expect($media?->file_name)->toBe('restock/new-photo.jpg');
|
||||
expect($restock->getMedia('photos'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('restock photo is removed when photo_key is null', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), makeValidRestockPayload([
|
||||
'photo_key' => 'restock/test-photo.jpg',
|
||||
]));
|
||||
|
||||
$restock = Restock::first();
|
||||
|
||||
$this->put(route('admin.manage.restocks.update', $restock), makeValidRestockPayload([
|
||||
'photo_key' => null,
|
||||
]));
|
||||
|
||||
expect($restock->fresh()->getMedia('photos'))->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('restock photo is kept when photo_key is unchanged', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), makeValidRestockPayload([
|
||||
'photo_key' => 'restock/same-photo.jpg',
|
||||
]));
|
||||
|
||||
$restock = Restock::first();
|
||||
|
||||
$this->put(route('admin.manage.restocks.update', $restock), makeValidRestockPayload([
|
||||
'photo_key' => 'restock/same-photo.jpg',
|
||||
]));
|
||||
|
||||
$media = $restock->fresh()->getFirstMedia('photos');
|
||||
expect($media?->file_name)->toBe('restock/same-photo.jpg');
|
||||
expect($restock->getMedia('photos'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('restock stock quality enum has labels', function () {
|
||||
expect(ProductStockQuality::GOOD->label())->toBe('Bagus');
|
||||
expect(ProductStockQuality::REJECT->label())->toBe('Reject');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| EDIT PAGE
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('edit page shows restock data', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$variant = makeRestockVariant(['name' => 'Hitam M']);
|
||||
$restock = Restock::factory()->create([
|
||||
'stock_type' => 'good',
|
||||
'notes' => 'Test notes',
|
||||
]);
|
||||
RestockItem::factory()->create([
|
||||
'restock_id' => $restock->id,
|
||||
'product_variant_id' => $variant->id,
|
||||
'quantity' => 10,
|
||||
'unit_price' => 50000,
|
||||
'subtotal' => 500000,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('admin.manage.restocks.edit', $restock));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/manage/restock/edit')
|
||||
->where('restock.id', $restock->id)
|
||||
->where('restock.stock_type', 'good')
|
||||
->where('restock.notes', 'Test notes')
|
||||
->has('restock.items', 1)
|
||||
->where('restock.items.0.product_variant_id', $variant->id)
|
||||
->where('restock.items.0.quantity', 10)
|
||||
->has('data.products')
|
||||
);
|
||||
});
|
||||
|
||||
test('create page returns products with capital price', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$variant = makeRestockVariant(['name' => 'Hitam M']);
|
||||
|
||||
$response = $this->get(route('admin.manage.restocks.create'));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->has('data.products', 1)
|
||||
->has('data.products.0.product_variants', 1)
|
||||
->where('data.products.0.product_variants.0.id', $variant->id)
|
||||
->where('data.products.0.product_variants.0.capital_price', 50000)
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| UPDATE / PUT
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('restock can be updated', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), makeValidRestockPayload());
|
||||
|
||||
$restock = Restock::first();
|
||||
|
||||
$variant = makeRestockVariant();
|
||||
$response = $this->put(route('admin.manage.restocks.update', $restock), [
|
||||
'stock_type' => 'reject',
|
||||
'items' => [
|
||||
['product_variant_id' => $variant->id, 'quantity' => 4],
|
||||
],
|
||||
'notes' => 'Updated notes',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect();
|
||||
|
||||
$restock->refresh();
|
||||
expect($restock->stock_type->value)->toBe('reject');
|
||||
expect($restock->notes)->toBe('Updated notes');
|
||||
expect($restock->subtotal)->toBe(200000);
|
||||
expect(RestockItem::where('restock_id', $restock->id)->count())->toBe(1);
|
||||
expect(RestockItem::first()->product_variant_id)->toBe($variant->id);
|
||||
});
|
||||
|
||||
test('restock update adjusts stock by difference', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$variant = makeRestockVariant(['stock' => 10, 'reject_stock' => 5]);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), [
|
||||
'stock_type' => 'good',
|
||||
'items' => [
|
||||
['product_variant_id' => $variant->id, 'quantity' => 10],
|
||||
],
|
||||
'notes' => null,
|
||||
]);
|
||||
|
||||
expect($variant->fresh()->stock)->toBe(20);
|
||||
|
||||
$restock = Restock::first();
|
||||
|
||||
$this->put(route('admin.manage.restocks.update', $restock), [
|
||||
'stock_type' => 'good',
|
||||
'items' => [
|
||||
['product_variant_id' => $variant->id, 'quantity' => 4],
|
||||
],
|
||||
'notes' => null,
|
||||
]);
|
||||
|
||||
expect($variant->fresh()->stock)->toBe(14);
|
||||
expect($variant->fresh()->reject_stock)->toBe(5);
|
||||
});
|
||||
|
||||
test('restock update moves stock when quality changes', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$variant = makeRestockVariant(['stock' => 10, 'reject_stock' => 5]);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), [
|
||||
'stock_type' => 'good',
|
||||
'items' => [
|
||||
['product_variant_id' => $variant->id, 'quantity' => 10],
|
||||
],
|
||||
'notes' => null,
|
||||
]);
|
||||
|
||||
expect($variant->fresh()->stock)->toBe(20);
|
||||
|
||||
$restock = Restock::first();
|
||||
|
||||
$this->put(route('admin.manage.restocks.update', $restock), [
|
||||
'stock_type' => 'reject',
|
||||
'items' => [
|
||||
['product_variant_id' => $variant->id, 'quantity' => 10],
|
||||
],
|
||||
'notes' => null,
|
||||
]);
|
||||
|
||||
$variant->refresh();
|
||||
expect($variant->stock)->toBe(10);
|
||||
expect($variant->reject_stock)->toBe(15);
|
||||
});
|
||||
|
||||
test('updating non-existent restock returns 404', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->put(route('admin.manage.restocks.update', 999999), makeValidRestockPayload());
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| DELETE / DESTROY
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('restock can be deleted and reduces product variant stock', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$variant = makeRestockVariant(['stock' => 10, 'reject_stock' => 5]);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), [
|
||||
'stock_type' => 'good',
|
||||
'items' => [
|
||||
['product_variant_id' => $variant->id, 'quantity' => 10],
|
||||
],
|
||||
'notes' => null,
|
||||
]);
|
||||
|
||||
$restock = Restock::first();
|
||||
expect($variant->fresh()->stock)->toBe(20);
|
||||
|
||||
$response = $this->delete(route('admin.manage.restocks.destroy', $restock));
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('admin.manage.restocks.index'));
|
||||
|
||||
$this->assertSoftDeleted('restocks', ['id' => $restock->id]);
|
||||
expect($variant->fresh()->stock)->toBe(10);
|
||||
expect($variant->fresh()->reject_stock)->toBe(5);
|
||||
});
|
||||
|
||||
test('restock delete reduces reject stock for reject restocks', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$variant = makeRestockVariant(['stock' => 10, 'reject_stock' => 5]);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), [
|
||||
'stock_type' => 'reject',
|
||||
'items' => [
|
||||
['product_variant_id' => $variant->id, 'quantity' => 7],
|
||||
],
|
||||
'notes' => null,
|
||||
]);
|
||||
|
||||
$restock = Restock::first();
|
||||
expect($variant->fresh()->reject_stock)->toBe(12);
|
||||
|
||||
$this->delete(route('admin.manage.restocks.destroy', $restock));
|
||||
|
||||
$this->assertSoftDeleted('restocks', ['id' => $restock->id]);
|
||||
expect($variant->fresh()->reject_stock)->toBe(5);
|
||||
});
|
||||
|
||||
test('delete cascades to restock items', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$variant = makeRestockVariant();
|
||||
$this->post(route('admin.manage.restocks.store'), [
|
||||
'stock_type' => 'good',
|
||||
'items' => [
|
||||
['product_variant_id' => $variant->id, 'quantity' => 10],
|
||||
],
|
||||
'notes' => null,
|
||||
]);
|
||||
|
||||
$restock = Restock::first();
|
||||
expect(RestockItem::where('restock_id', $restock->id)->count())->toBe(1);
|
||||
|
||||
$this->delete(route('admin.manage.restocks.destroy', $restock));
|
||||
|
||||
expect(RestockItem::where('restock_id', $restock->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('restock delete removes attached photo', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->post(route('admin.manage.restocks.store'), makeValidRestockPayload([
|
||||
'photo_key' => 'restock/delete-photo.jpg',
|
||||
]));
|
||||
|
||||
$restock = Restock::first();
|
||||
expect($restock->getFirstMedia('photos')?->file_name)
|
||||
->toBe('restock/delete-photo.jpg');
|
||||
|
||||
$this->delete(route('admin.manage.restocks.destroy', $restock));
|
||||
|
||||
expect($restock->fresh()->getMedia('photos'))->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('deleting non-existent restock returns 404', function () {
|
||||
$user = User::factory()->create();
|
||||
giveRestockPermissions($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->delete(route('admin.manage.restocks.destroy', 999999));
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user