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:
Yoga Pangestu 2026-08-02 22:34:10 +07:00
parent ab3ec86931
commit cf0e772159
21 changed files with 3297 additions and 31 deletions

View File

@ -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',
};
}
}

View 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'
);
}
}

View 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',
];
}
}

View File

@ -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]

View File

@ -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]

View File

@ -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]

View 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,
);
}
}
}

View File

@ -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']),
];
}
}

View File

@ -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(),
];

View File

@ -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'

View File

@ -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 },
];

View 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]);
}

View 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
}
}

View 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[];
};

View 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>
</>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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');
});
});

View 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);
});