feat: implement product variant management

- Add a custom hook `useCardTableExpand` for managing expandable card tables.
- Refactor product columns to include variant edit and delete handlers.
- Integrate `CardTable` component for displaying products with expandable variant details.
- Create `ProductCardRow` component for rendering product information in a card format.
- Implement variant editing functionality with a dedicated `ProductVariantEdit` component.
- Add `VariantSubRow` component to display variant details in a table format.
- Update routes to handle product variant editing and deletion.
- Enhance tests to cover variant editing and deletion scenarios.
This commit is contained in:
Yoga Pangestu 2026-08-01 13:19:07 +07:00
parent 97f114136b
commit e3c0f14c43
16 changed files with 1661 additions and 225 deletions

View File

@ -1,13 +1,13 @@
<?php
namespace App\Http\Controllers\Admin\Master;
namespace App\Http\Controllers\Admin\Master\Product;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Master\ProductRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Product;
use App\Services\Admin\Master\CategoryService;
use App\Services\Admin\Master\ProductService;
use App\Services\Admin\Master\Product\ProductService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -24,9 +24,10 @@ public function index(PaginatedRequest $request): Response
return Inertia::render('admin/master/product/index', [
'products' => $this->service->paginated(
...$request->validatedWithDefaults(),
filters: $request->only(['status']),
filters: $request->only(['status', 'stock', 'category']),
),
'filters' => $request->only(['status']),
'categories' => $this->categoryService->getAll(),
'filters' => $request->only(['status', 'stock', 'category']),
]);
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Admin\Master\Product;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Master\Product\ProductVariantRequest;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Services\Admin\Master\Product\ProductVariantService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class ProductVariantController extends Controller
{
public function __construct(
private ProductVariantService $variantService,
) {}
public function edit(Product $product, ProductVariant $variant): Response
{
return Inertia::render('admin/master/product/variant/edit', [
'variant' => $this->variantService->getForEdit($variant),
]);
}
public function update(ProductVariantRequest $request, Product $product, ProductVariant $variant): RedirectResponse
{
return $this->handleAction(
fn () => $this->variantService->update($variant, $request->validated()),
'Varian berhasil diperbarui.',
'admin.master.products.index'
);
}
public function destroy(Product $product, ProductVariant $variant): RedirectResponse
{
return $this->handleAction(
fn () => $this->variantService->delete($product, $variant),
'Varian berhasil dihapus.',
'admin.master.products.index'
);
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Http\Requests\Admin\Master\Product;
use App\Enums\PriceType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProductVariantRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function prepareForValidation(): void
{
$prices = $this->prices;
if (is_array($prices)) {
foreach ($prices as $i => $price) {
if (isset($price['price']) && is_string($price['price'])) {
$this->request->set("prices.$i.price", (int) str_replace('.', '', $price['price']));
}
}
}
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:200'],
'stock' => ['required', 'integer', 'min:0'],
'reject_stock' => ['required', 'integer', 'min:0'],
'retail_stock' => ['required', 'integer', 'min:0'],
'photo_key' => ['required', 'string', 'max:500'],
'prices' => ['required', 'array', 'size:9'],
'prices.*.type' => ['required', Rule::in(PriceType::values())],
'prices.*.price' => ['required', 'integer', 'min:0'],
];
}
public function attributes(): array
{
return [
'name' => 'Nama Varian',
'stock' => 'Stok Bagus',
'reject_stock' => 'Stok Reject',
'retail_stock' => 'Stok Ecer',
'photo_key' => 'Foto',
'prices' => 'Harga',
'prices.*.type' => 'Tipe Harga',
'prices.*.price' => 'Harga',
];
}
}

View File

@ -1,22 +1,19 @@
<?php
namespace App\Services\Admin\Master;
namespace App\Services\Admin\Master\Product;
use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class ProductService
{
public function __construct(
private S3PresignedService $s3Service = new S3PresignedService,
private ProductVariantService $variantService = new ProductVariantService,
) {}
public function getAll(array $filters = []): Collection
@ -35,10 +32,7 @@ public function getAll(array $filters = []): Collection
$products->each(function ($product) {
$product->productVariants->each(function ($variant) {
$media = $variant->getMedia('photos')->first();
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
$variant->photo_url = $this->variantService->getTemporaryUrl($variant);
});
});
@ -56,15 +50,21 @@ public function paginated(int $perPage = 15, string $search = '', string $sort =
])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
$cq->where('categories.id', $categoryId);
}))
->when(($filters['stock'] ?? null) === 'empty', function ($q) {
$q->whereRaw('(SELECT IFNULL(SUM(stock), 0) FROM product_variants WHERE product_variants.product_id = products.id AND product_variants.deleted_at IS NULL) = 0');
})
->when(($filters['stock'] ?? null) === 'low', function ($q) {
$q->whereRaw('(SELECT IFNULL(SUM(stock), 0) FROM product_variants WHERE product_variants.product_id = products.id AND product_variants.deleted_at IS NULL) BETWEEN 1 AND 9');
})
->orderBy($sort, $direction)
->paginate($perPage);
$paginator->getCollection()->each(function ($product) {
$product->productVariants->each(function ($variant) {
$media = $variant->getMedia('photos')->first();
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
$variant->photo_url = $this->variantService->getTemporaryUrl($variant);
});
});
@ -105,7 +105,7 @@ public function create(array $data): Product
}
if (! empty($variantData['photo_key'])) {
$this->registerPhotos($variant, [$variantData['photo_key']]);
$this->variantService->registerPhotos($variant, [$variantData['photo_key']]);
}
}
@ -131,16 +131,14 @@ public function getForEdit(Product $product): array
]);
$variants = $product->productVariants->map(function (ProductVariant $variant) {
$media = $variant->getMedia('photos')->first();
return [
'id' => $variant->id,
'name' => $variant->name,
'stock' => $variant->stock,
'reject_stock' => $variant->reject_stock,
'retail_stock' => $variant->retail_stock,
'photo_key' => $media?->file_name,
'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null,
'photo_key' => $variant->getMedia('photos')->first()?->file_name,
'photo_url' => $this->variantService->getTemporaryUrl($variant),
'prices' => $variant->productPrices->map(fn ($p) => [
'type' => $p->type->value,
'price' => $p->price,
@ -187,16 +185,22 @@ public function update(Product $product, array $data): Product
foreach ($data['variants'] as $variantData) {
$variantId = $variantData['id'] ?? null;
$variant = $variantId
? $product->productVariants()->findOrFail($variantId)
: $product->productVariants()->create([]);
$variant->update([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'reject_stock' => $variantData['reject_stock'],
'retail_stock' => $variantData['retail_stock'],
]);
if ($variantId) {
$variant = $product->productVariants()->findOrFail($variantId);
$variant->update([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'reject_stock' => $variantData['reject_stock'],
'retail_stock' => $variantData['retail_stock'],
]);
} else {
$variant = $product->productVariants()->create([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'reject_stock' => $variantData['reject_stock'],
'retail_stock' => $variantData['retail_stock'],
]);
}
$variant->productPrices()->delete();
@ -214,7 +218,7 @@ public function update(Product $product, array $data): Product
if (! empty($variantData['photo_key'])) {
$variant->clearMediaCollection('photos');
$this->registerPhotos($variant, [$variantData['photo_key']]);
$this->variantService->registerPhotos($variant, [$variantData['photo_key']]);
}
}
@ -233,7 +237,7 @@ public function update(Product $product, array $data): Product
public function delete(Product $product): bool
{
return DB::transaction(function () use ($product) {
$result = DB::transaction(function () use ($product) {
$product->productVariants->each(function (ProductVariant $variant) {
$variant->productPrices()->delete();
$variant->clearMediaCollection('photos');
@ -244,6 +248,15 @@ public function delete(Product $product): bool
return $product->delete();
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Produk Dihapus',
body: "Produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
);
return $result;
}
public function toggleStatus(Product $product): void
@ -252,30 +265,4 @@ public function toggleStatus(Product $product): void
'status' => $product->status->value === 'active' ? 'inactive' : 'active',
]);
}
private function registerPhotos(ProductVariant $variant, array $photoKeys): void
{
foreach ($photoKeys as $order => $s3Key) {
$fileName = pathinfo($s3Key, PATHINFO_BASENAME);
$name = pathinfo($s3Key, PATHINFO_FILENAME);
Media::create([
'model_type' => ProductVariant::class,
'model_id' => $variant->id,
'uuid' => Str::uuid(),
'collection_name' => 'photos',
'name' => $name,
'file_name' => $s3Key,
'mime_type' => 'image/jpeg',
'disk' => 's3',
'conversions_disk' => 's3',
'size' => 0,
'manipulations' => [],
'custom_properties' => [],
'generated_conversions' => [],
'responsive_images' => [],
'order_column' => $order + 1,
]);
}
}
}

View File

@ -0,0 +1,129 @@
<?php
namespace App\Services\Admin\Master\Product;
use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class ProductVariantService
{
public function __construct(
private S3PresignedService $s3Service = new S3PresignedService,
) {}
public function getForEdit(ProductVariant $variant): array
{
$variant->load('productPrices');
$media = $variant->getMedia('photos')->first();
return [
'id' => $variant->id,
'product_id' => $variant->product_id,
'name' => $variant->name,
'stock' => $variant->stock,
'reject_stock' => $variant->reject_stock,
'retail_stock' => $variant->retail_stock,
'photo_key' => $media?->file_name,
'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null,
'prices' => $variant->productPrices->map(fn ($p) => [
'type' => $p->type->value,
'price' => $p->price,
]),
];
}
public function update(ProductVariant $variant, array $data): ProductVariant
{
DB::transaction(function () use ($variant, $data) {
$variant->update([
'name' => $data['name'],
'stock' => $data['stock'],
'reject_stock' => $data['reject_stock'],
'retail_stock' => $data['retail_stock'],
]);
$variant->productPrices()->delete();
foreach ($data['prices'] as $priceData) {
ProductPrice::create([
'variant_id' => $variant->id,
'type' => $priceData['type'],
'price' => $priceData['price'],
]);
}
if (! empty($data['photo_key'])) {
$variant->clearMediaCollection('photos');
$this->registerPhotos($variant, [$data['photo_key']]);
}
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Varian Diperbarui',
body: "Varian \"{$variant->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
);
return $variant->fresh();
}
public function delete(Product $product, ProductVariant $variant): bool
{
$result = DB::transaction(function () use ($variant) {
$variant->productPrices()->delete();
$variant->clearMediaCollection('photos');
return $variant->delete();
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Varian Dihapus',
body: "Varian \"{$variant->name}\" dari produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
);
return $result;
}
public function registerPhotos(ProductVariant $variant, array $photoKeys): void
{
foreach ($photoKeys as $order => $s3Key) {
$fileName = pathinfo($s3Key, PATHINFO_BASENAME);
$name = pathinfo($s3Key, PATHINFO_FILENAME);
Media::create([
'model_type' => ProductVariant::class,
'model_id' => $variant->id,
'uuid' => Str::uuid(),
'collection_name' => 'photos',
'name' => $name,
'file_name' => $s3Key,
'mime_type' => 'image/jpeg',
'disk' => 's3',
'conversions_disk' => 's3',
'size' => 0,
'manipulations' => [],
'custom_properties' => [],
'generated_conversions' => [],
'responsive_images' => [],
'order_column' => $order + 1,
]);
}
}
public function getTemporaryUrl(ProductVariant $variant): ?string
{
$media = $variant->getMedia('photos')->first();
return $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null;
}
}

View File

@ -0,0 +1,235 @@
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Search,
} from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export interface PaginationState {
current_page: number;
last_page: number;
per_page: number;
total: number;
}
interface CardRenderContext<TData> {
item: TData;
index: number;
isExpanded: boolean;
onToggleExpand: () => void;
}
interface CardTableProps<TData> {
data: TData[];
getItemKey: (item: TData) => number | string;
renderCard: (ctx: CardRenderContext<TData>) => React.ReactNode;
renderSubContent: (item: TData) => React.ReactNode;
expandedKeys: Set<number | string> | 'all';
onToggleExpand: (key: number | string) => void;
searchPlaceholder?: string;
searchValue?: string;
onSearchChange?: (value: string) => void;
toolbar?: React.ReactNode;
pagination?: PaginationState;
onPageChange?: (page: number) => void;
onPerPageChange?: (perPage: number) => void;
emptyText?: string;
}
function useDebounce(callback: (value: string) => void, delay: number) {
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
return React.useCallback(
(value: string) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(value);
}, delay);
},
[callback, delay],
);
}
export function CardTable<TData>({
data,
getItemKey,
renderCard,
renderSubContent,
expandedKeys,
onToggleExpand,
searchPlaceholder = 'Cari...',
searchValue,
onSearchChange,
toolbar,
pagination,
onPageChange,
onPerPageChange,
emptyText = 'Tidak ada data.',
}: CardTableProps<TData>) {
const [localSearch, setLocalSearch] = React.useState(searchValue ?? '');
React.useEffect(() => {
setLocalSearch(searchValue ?? '');
}, [searchValue]);
const isServerMode = !!pagination && !!onPageChange;
const handleSearchDebounced = useDebounce(
(value: string) => onSearchChange?.(value),
300,
);
function handleSearchChange(value: string) {
setLocalSearch(value);
if (isServerMode) {
handleSearchDebounced(value);
}
}
function isItemExpanded(key: number | string): boolean {
if (expandedKeys === 'all') return true;
return expandedKeys.has(key);
}
const totalPages = pagination?.last_page ?? 1;
const currentPage = pagination?.current_page ?? 1;
return (
<>
{(onSearchChange || toolbar || isServerMode) && (
<div className="flex items-center gap-2">
{onSearchChange && (
<div className="relative max-w-sm flex-1">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={searchPlaceholder}
value={localSearch}
onChange={(e) =>
handleSearchChange(e.target.value)
}
className="pl-9"
/>
</div>
)}
{toolbar}
<div className="ml-auto flex items-center gap-2">
{isServerMode && onPerPageChange && (
<Select
value={String(pagination?.per_page ?? 25)}
onValueChange={(value) =>
onPerPageChange(Number(value))
}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="999999">
Semua
</SelectItem>
</SelectContent>
</Select>
)}
</div>
</div>
)}
<div className="flex flex-col gap-3">
{data.length === 0 ? (
<Card>
<CardContent className="flex h-24 items-center justify-center text-muted-foreground">
{emptyText}
</CardContent>
</Card>
) : (
data.map((item, index) => {
const key = getItemKey(item);
const isExpanded = isItemExpanded(key);
return (
<div key={key} className="flex flex-col">
{renderCard({
item,
index,
isExpanded,
onToggleExpand: () => onToggleExpand(key),
})}
{isExpanded && (
<div className="rounded-b-lg border border-t-0 bg-muted/30 p-4">
{renderSubContent(item)}
</div>
)}
</div>
);
})
)}
</div>
{isServerMode && (
<div className="flex items-center justify-end gap-2">
<span className="text-sm text-muted-foreground">
Halaman {currentPage} dari {totalPages}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(1)}
disabled={currentPage <= 1}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(totalPages)}
disabled={currentPage >= totalPages}
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,48 @@
import { useCallback, useState } from 'react';
type ExpandState = Set<number | string> | 'all';
export function useCardTableExpand(
defaultExpanded: boolean | (number | string)[] = false,
) {
const [expandedKeys, setExpandedKeys] = useState<ExpandState>(() => {
if (defaultExpanded === true) return 'all';
if (Array.isArray(defaultExpanded)) return new Set(defaultExpanded);
return new Set();
});
const toggleExpand = useCallback((key: number | string) => {
setExpandedKeys((prev) => {
if (prev === 'all') {
return new Set([key]);
}
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
const expandAll = useCallback((keys: (number | string)[]) => {
setExpandedKeys(new Set(keys));
}, []);
const collapseAll = useCallback(() => {
setExpandedKeys(new Set());
}, []);
const isExpanded = useCallback(
(key: number | string): boolean => {
if (expandedKeys === 'all') return true;
return expandedKeys.has(key);
},
[expandedKeys],
);
return { expandedKeys, toggleExpand, expandAll, collapseAll, isExpanded };
}
export type { ExpandState };

View File

@ -161,7 +161,7 @@ export function NotificationBell() {
<span className="sr-only">Notifikasi</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 max-h-[350px]">
<DropdownMenuContent align="end" className="max-h-[350px] w-80">
<div className="flex items-center justify-between border-b px-4 py-2">
<span className="text-sm font-semibold">Notifikasi</span>
{unreadCount > 0 && (

View File

@ -83,13 +83,24 @@ function getFilteredVariants(
type CreateColumnsParams = {
handleEdit: (product: Product) => void;
handleDeleteClick: (product: Product) => void;
handleVariantEdit: (product: Product) => void;
handleVariantDeleteClick: (
product: Product,
variant: ProductVariant,
) => void;
toggleStatusUrl: (id: number) => string;
};
export function createProductColumns(
params: CreateColumnsParams,
): ColumnDef<Product>[] {
const { handleEdit, handleDeleteClick, toggleStatusUrl } = params;
const {
handleEdit,
handleDeleteClick,
handleVariantEdit,
handleVariantDeleteClick,
toggleStatusUrl,
} = params;
return [
{

View File

@ -1,11 +1,9 @@
import { Head, router } from '@inertiajs/react';
import type { Row } from '@tanstack/react-table';
import { Filter, Plus, X } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { CardTable } from '@/components/card-table';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { ConfirmDialog } from '@/components/confirm-dialog';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { ImagePreviewModal } from '@/components/image-preview-modal';
import { Button } from '@/components/ui/button';
import {
Combobox,
@ -27,14 +25,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
destroy,
create as productCreate,
@ -42,8 +32,13 @@ import {
edit as productEdit,
toggleStatus,
} from '@/routes/admin/master/products';
import type { Product } from './columns';
import { createProductColumns } from './columns';
import {
destroy as variantDestroy,
edit as variantEdit,
} from '@/routes/admin/master/products/variants';
import type { Product, ProductVariant } from './columns';
import { ProductCardRow } from './product-card';
import { VariantSubRow } from './variant/sub-row';
type Props = {
products: {
@ -53,145 +48,31 @@ type Props = {
per_page: number;
total: number;
};
categories: {
id: number;
name: string;
}[];
filters: {
status?: string;
name?: string;
stock?: string;
category?: string;
};
};
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').format(num);
}
function VariantPhotoPreview({ url, title }: { url: string; title: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className="block h-10 w-10 overflow-hidden rounded-md border bg-muted 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}
/>
</>
);
}
function VariantSubRow({
row,
searchValue,
}: {
row: Row<Product>;
searchValue?: string;
}) {
const allVariants = row.original.product_variants ?? [];
const query = (searchValue ?? '').toLowerCase().trim();
const variants = query
? allVariants.filter((v) => v.name.toLowerCase().includes(query))
: allVariants;
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">Foto</TableHead>
<TableHead className="w-[200px]">Nama Varian</TableHead>
<TableHead className="text-center">Stok Bagus</TableHead>
<TableHead className="text-center">Stok Reject</TableHead>
<TableHead className="text-center">Stok Ecer</TableHead>
<TableHead>Harga</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{variants.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
Tidak ada varian.
</TableCell>
</TableRow>
) : (
variants.map((variant) => (
<TableRow key={variant.id}>
<TableCell>
{variant.photo_url ? (
<VariantPhotoPreview
url={variant.photo_url}
title={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 className="font-medium">
{variant.name}
</TableCell>
<TableCell className="text-center">
{formatNumber(variant.stock)}
</TableCell>
<TableCell className="text-center">
{formatNumber(variant.reject_stock)}
</TableCell>
<TableCell className="text-center">
{formatNumber(variant.retail_stock)}
</TableCell>
<TableCell>
{variant.product_prices?.length > 0 ? (
<div className="flex flex-col gap-0.5">
{variant.product_prices.map((p) => (
<span
key={p.id}
className="text-xs"
>
<span className="text-muted-foreground">
{p.type_label}:
</span>{' '}
{formatCurrency(p.price)}
</span>
))}
</div>
) : (
'-'
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
);
}
export default function ProductIndex({ products, filters }: Props) {
export default function ProductIndex({ products, categories, filters }: Props) {
const [deleting, setDeleting] = useState<Product | null>(null);
const [deletingVariant, setDeletingVariant] = useState<{
product: Product;
variant: ProductVariant;
} | null>(null);
const [filterOpen, setFilterOpen] = useState(false);
const [search, setSearch] = useState('');
const hasActiveFilters = filters.status || filters.name;
const expand = useCardTableExpand(true);
const hasActiveFilters =
filters.status || filters.name || filters.stock || filters.category;
const pagination: PaginationState = {
const pagination = {
current_page: products.current_page,
last_page: products.last_page,
per_page: products.per_page,
@ -200,7 +81,6 @@ export default function ProductIndex({ products, filters }: Props) {
const productNames = useMemo(() => {
const names = products.data.map((p) => p.name);
return [...new Set(names)].sort();
}, [products.data]);
@ -284,13 +164,21 @@ export default function ProductIndex({ products, filters }: Props) {
});
}
const columns = createProductColumns({
handleEdit: (product) => {
window.location.href = productEdit.url(product.id);
},
handleDeleteClick: (product) => setDeleting(product),
toggleStatusUrl: (id) => toggleStatus.url(id),
});
function handleDeleteVariant() {
if (!deletingVariant) {
return;
}
router.delete(
variantDestroy.url({
product: deletingVariant.product.id,
variant: deletingVariant.variant.id,
}),
{
onSuccess: () => setDeletingVariant(null),
},
);
}
const filterToolbar = (
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
@ -376,6 +264,64 @@ export default function ProductIndex({ products, filters }: Props) {
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Stok
</label>
<span className="-mt-1 text-[0.65rem] text-muted-foreground/70">
Berdasarkan stok bagus
</span>
<Select
value={filters.stock ?? 'all'}
onValueChange={(value) =>
applyFilter('stock', value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Stok" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Stok</SelectItem>
<SelectItem value="empty">Habis</SelectItem>
<SelectItem value="low">
Menipis (di bawah 10)
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Kategori
</label>
<Combobox
value={filters.category ?? ''}
onValueChange={(value) =>
applyFilter('category', value as string)
}
>
<ComboboxInput
placeholder="Pilih kategori..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada kategori ditemukan.
</ComboboxEmpty>
<ComboboxList>
{categories.map((cat) => (
<ComboboxItem
key={cat.id}
value={String(cat.id)}
>
{cat.name}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</div>
</PopoverContent>
</Popover>
@ -400,22 +346,55 @@ export default function ProductIndex({ products, filters }: Props) {
</Button>
</div>
<DataTable
columns={columns}
<CardTable
data={products.data}
searchKey="name"
getItemKey={(p) => p.id}
expandedKeys={expand.expandedKeys}
onToggleExpand={expand.toggleExpand}
searchValue={search}
onSearchChange={handleSearchChange}
searchPlaceholder="Cari produk..."
emptyText="Belum ada data produk."
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
renderSubRow={(row, searchValue) => (
<VariantSubRow row={row} searchValue={searchValue} />
)}
defaultExpanded
toolbar={filterToolbar}
renderCard={({
item,
index,
isExpanded,
onToggleExpand,
}) => (
<ProductCardRow
product={item}
index={
(pagination.current_page - 1) *
pagination.per_page +
index +
1
}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
onEdit={(p) => {
window.location.href = productEdit.url(p.id);
}}
onDelete={(p) => setDeleting(p)}
toggleStatusUrl={(id) => toggleStatus.url(id)}
/>
)}
renderSubContent={(product) => (
<VariantSubRow
product={product}
onEditVariant={(p, v) => {
window.location.href = variantEdit.url({
product: p.id,
variant: v.id,
});
}}
onDeleteVariantClick={(p, v) =>
setDeletingVariant({ product: p, variant: v })
}
/>
)}
/>
<ConfirmDialog
@ -430,6 +409,19 @@ export default function ProductIndex({ products, filters }: Props) {
confirmLabel="Hapus"
onConfirm={handleDelete}
/>
<ConfirmDialog
open={deletingVariant !== null}
onOpenChange={(open) => {
if (!open) {
setDeletingVariant(null);
}
}}
title="Hapus Varian"
description={`Apakah Anda yakin ingin menghapus varian "${deletingVariant?.variant.name}" dari produk "${deletingVariant?.product.name}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
onConfirm={handleDeleteVariant}
/>
</div>
</>
);

View File

@ -0,0 +1,197 @@
import { router } from '@inertiajs/react';
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { Product } from './columns';
function formatNumber(num: number): string {
return new Intl.NumberFormat('id-ID').format(num);
}
function getStatusLabel(status: string): string {
const labels: Record<string, string> = {
active: 'Aktif',
inactive: 'Non Aktif',
draft: 'Draft',
};
return labels[status] ?? status;
}
function getStatusVariant(status: string): string {
const variants: Record<string, string> = {
active: 'bg-green-100 text-green-800',
inactive: 'bg-red-100 text-red-800',
draft: 'bg-yellow-100 text-yellow-800',
};
return variants[status] ?? 'bg-gray-100 text-gray-800';
}
export type ProductCardRowParams = {
product: Product;
index: number;
isExpanded: boolean;
onToggleExpand: () => void;
onEdit: (product: Product) => void;
onDelete: (product: Product) => void;
toggleStatusUrl: (id: number) => string;
};
export function ProductCardRow({
product,
index,
isExpanded,
onToggleExpand,
onEdit,
onDelete,
toggleStatusUrl,
}: ProductCardRowParams) {
const variants = product.product_variants ?? [];
const totalStock = variants.reduce((sum, v) => sum + (v.stock ?? 0), 0);
const totalReject = variants.reduce(
(sum, v) => sum + (v.reject_stock ?? 0),
0,
);
const totalRetail = variants.reduce(
(sum, v) => sum + (v.retail_stock ?? 0),
0,
);
const totalAll = totalStock + totalReject + totalRetail;
const isToggleable =
product.status === 'active' || product.status === 'inactive';
const isChecked = product.status === 'active';
function handleToggle() {
router.post(toggleStatusUrl(product.id), {}, { preserveScroll: true });
}
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">
{product.name}
</h3>
{product.categories?.length > 0 && (
<span className="text-xs text-muted-foreground">
(
{product.categories
.map((c) => c.name)
.join(', ')}
)
</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">
{variants.length} varian
</span>
<span>
Bagus:{' '}
<span className="font-medium text-foreground">
{formatNumber(totalStock)}
</span>
</span>
<span>
Reject:{' '}
<span className="font-medium text-foreground">
{formatNumber(totalReject)}
</span>
</span>
<span>
Ecer:{' '}
<span className="font-medium text-foreground">
{formatNumber(totalRetail)}
</span>
</span>
<span>
Total:{' '}
<span className="font-medium text-foreground">
{formatNumber(totalAll)}
</span>
</span>
</div>
<div className="mt-2">
{isToggleable ? (
<div className="flex items-center gap-2">
<Switch
size="sm"
checked={isChecked}
onCheckedChange={handleToggle}
/>
<span
className={`text-xs font-medium ${isChecked ? 'text-green-700' : 'text-red-700'}`}
>
{getStatusLabel(product.status)}
</span>
</div>
) : (
<span
className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${getStatusVariant(product.status)}`}
>
{getStatusLabel(product.status)}
</span>
)}
</div>
</div>
<TooltipProvider>
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(product)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(product)}
>
<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,271 @@
import InputError from '@/components/input-error';
import { RupiahInput } from '@/components/rupiah-input';
import { FileUpload } from '@/components/file-upload';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { index as productIndex } from '@/routes/admin/master/products';
import { Form, Head } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react';
import { useState } from 'react';
type Props = {
variant: {
id: number;
product_id: number;
name: string;
stock: number;
reject_stock: number;
retail_stock: number;
photo_key: string | null;
photo_url: string | null;
prices: Array<{ type: string; price: number }>;
};
};
const PRICE_TYPES = [
{ key: 'distributor', label: 'Distributor' },
{ key: 'agent', label: 'Agen' },
{ key: 'sub_agent', label: 'Sub Agen' },
{ key: 'wholesale', label: 'Grosir' },
{ key: 'retail', label: 'Ecer' },
{ key: 'tiktok', label: 'TikTok' },
{ key: 'shopee', label: 'Shopee' },
{ key: 'capital', label: 'Modal' },
{ key: 'reject', label: 'Reject' },
];
export default function ProductVariantEdit({ variant }: Props) {
const [name, setName] = useState(variant.name);
const [stock, setStock] = useState(variant.stock);
const [rejectStock, setRejectStock] = useState(variant.reject_stock);
const [retailStock, setRetailStock] = useState(variant.retail_stock);
const [photo, setPhoto] = useState<string | null>(variant.photo_key);
const [uploading, setUploading] = useState(false);
const [prices, setPrices] = useState<
Array<{ type: string; price: number }>
>(
variant.prices.length > 0
? variant.prices
: PRICE_TYPES.map((pt) => ({ type: pt.key, price: 0 })),
);
function updatePrice(priceIndex: number, value: number) {
setPrices((prev) =>
prev.map((p, i) => (i === priceIndex ? { ...p, price: value } : p)),
);
}
function getPayload() {
return {
name,
stock: Number(stock),
reject_stock: Number(rejectStock),
retail_stock: Number(retailStock),
photo_key: photo,
prices: prices.map((p) => ({
type: p.type,
price: Number(p.price),
})),
};
}
return (
<>
<Head title="Edit Varian" />
<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 Varian
</h2>
<Button asChild variant="outline">
<a href={productIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</a>
</Button>
</div>
<Form
action={`/admin/master/products/${variant.product_id}/variants/${variant.id}`}
method="put"
transform={() => getPayload()}
>
{({ errors, processing }) => (
<>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Informasi Varian</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="grid gap-2">
<Label>
Nama Varian{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
value={name}
onChange={(e) =>
setName(e.target.value)
}
placeholder="Contoh: Ukuran L, Warna Merah"
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label>
Stok Bagus{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
type="number"
min={0}
value={stock}
onChange={(e) =>
setStock(
Number(e.target.value),
)
}
/>
<InputError
message={errors.stock}
/>
</div>
<div className="grid gap-2">
<Label>
Stok Reject{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
type="number"
min={0}
value={rejectStock}
onChange={(e) =>
setRejectStock(
Number(e.target.value),
)
}
/>
<InputError
message={errors.reject_stock}
/>
</div>
<div className="grid gap-2">
<Label>
Stok Ecer{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
type="number"
min={0}
value={retailStock}
onChange={(e) =>
setRetailStock(
Number(e.target.value),
)
}
/>
<InputError
message={errors.retail_stock}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Foto Varian</CardTitle>
</CardHeader>
<CardContent>
<FileUpload
value={photo}
onChange={setPhoto}
folder="product-variant"
existingUrl={variant.photo_url}
onUploadingChange={setUploading}
/>
<InputError
message={errors.photo_key}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Harga</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
{PRICE_TYPES.map(
(priceType, priceIndex) => (
<div
key={priceType.key}
className="grid gap-2"
>
<Label>
Harga{' '}
{priceType.label}{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
value={
prices[
priceIndex
]?.price ?? 0
}
onValueChange={(
val,
) =>
updatePrice(
priceIndex,
val,
)
}
/>
<InputError
message={
errors[
`prices.${priceIndex}.price`
]
}
/>
</div>
),
)}
</div>
</CardContent>
</Card>
</div>
<div className="mt-6 flex items-center gap-4">
<Button
type="submit"
disabled={processing || uploading}
>
{processing
? 'Menyimpan...'
: uploading
? 'Mengunggah...'
: 'Simpan'}
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}

View File

@ -0,0 +1,202 @@
import { Pencil, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { ImagePreviewModal } from '@/components/image-preview-modal';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { Product, ProductVariant } 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').format(num);
}
function VariantPhotoPreview({ url, title }: { url: string; title: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className="block h-10 w-10 overflow-hidden rounded-md border bg-muted 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 VariantSubRow({
product,
onEditVariant,
onDeleteVariantClick,
}: {
product: Product;
onEditVariant: (product: Product, variant: ProductVariant) => void;
onDeleteVariantClick: (product: Product, variant: ProductVariant) => void;
}) {
const variants = product.product_variants ?? [];
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px] text-center">
No
</TableHead>
<TableHead className="w-[60px]">Foto</TableHead>
<TableHead className="w-[200px]">Nama Varian</TableHead>
<TableHead className="text-center">
Stok Bagus
</TableHead>
<TableHead className="text-center">
Stok Reject
</TableHead>
<TableHead className="text-center">Stok Ecer</TableHead>
<TableHead>Harga</TableHead>
<TableHead className="w-[80px] text-center">
Aksi
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{variants.length === 0 ? (
<TableRow>
<TableCell
colSpan={8}
className="text-center text-muted-foreground"
>
Tidak ada varian.
</TableCell>
</TableRow>
) : (
variants.map((variant, index) => (
<TableRow key={variant.id}>
<TableCell className="text-center">
{index + 1}
</TableCell>
<TableCell>
{variant.photo_url ? (
<VariantPhotoPreview
url={variant.photo_url}
title={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 className="font-medium">
{variant.name}
</TableCell>
<TableCell className="text-center">
{formatNumber(variant.stock)}
</TableCell>
<TableCell className="text-center">
{formatNumber(variant.reject_stock)}
</TableCell>
<TableCell className="text-center">
{formatNumber(variant.retail_stock)}
</TableCell>
<TableCell>
{variant.product_prices?.length > 0 ? (
<div className="flex flex-col gap-0.5">
{variant.product_prices.map((p) => (
<span
key={p.id}
className="text-xs"
>
<span className="text-muted-foreground">
{p.type_label}:
</span>{' '}
{formatCurrency(p.price)}
</span>
))}
</div>
) : (
'-'
)}
</TableCell>
<TableCell>
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
onEditVariant(
product,
variant,
)
}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
onDeleteVariantClick(
product,
variant,
)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -12,7 +12,8 @@
use App\Http\Controllers\Admin\HR\LeaveRequestController;
use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController;
use App\Http\Controllers\Admin\Master\ProductController;
use App\Http\Controllers\Admin\Master\Product\ProductController;
use App\Http\Controllers\Admin\Master\Product\ProductVariantController;
use App\Http\Controllers\Admin\Master\SupplierController;
use App\Http\Controllers\Admin\RoleController;
use Illuminate\Support\Facades\Route;
@ -33,6 +34,9 @@
Route::resource('categories', CategoryController::class)->except(['show', 'create', 'edit']);
Route::resource('products', ProductController::class)->except(['show']);
Route::post('products/{product}/toggle-status', [ProductController::class, 'toggleStatus'])->name('products.toggle-status');
Route::delete('products/{product}/variants/{variant}', [ProductVariantController::class, 'destroy'])->name('products.variants.destroy');
Route::get('products/{product}/variants/{variant}/edit', [ProductVariantController::class, 'edit'])->name('products.variants.edit');
Route::put('products/{product}/variants/{variant}', [ProductVariantController::class, 'update'])->name('products.variants.update');
Route::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit']);
Route::resource('customers', CustomerController::class)->except(['show', 'create', 'edit']);
});

View File

@ -2006,3 +2006,263 @@ function allPriceTypes(): array
// but shared_prices is empty, so it should fail
$response->assertSessionHasErrors('shared_prices');
});
/*
|--------------------------------------------------------------------------
| PRODUCT VARIANT - EDIT PAGE
|--------------------------------------------------------------------------
*/
test('guest cannot access variant edit page', function () {
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->get(route('admin.master.products.variants.edit', [$product, $variant]));
$response->assertRedirect(route('login'));
});
test('authenticated user can access variant edit page', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->get(route('admin.master.products.variants.edit', [$product, $variant]));
$response->assertStatus(200);
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/variant/edit')
->has('variant')
);
});
test('variant edit page shows variant data', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create([
'name' => 'Varian Edit Test',
'stock' => 100,
'reject_stock' => 10,
'retail_stock' => 20,
]);
$response = $this->get(route('admin.master.products.variants.edit', [$product, $variant]));
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/variant/edit')
->where('variant.id', $variant->id)
->where('variant.product_id', $product->id)
->where('variant.name', 'Varian Edit Test')
->where('variant.stock', 100)
->where('variant.reject_stock', 10)
->where('variant.retail_stock', 20)
);
});
/*
|--------------------------------------------------------------------------
| PRODUCT VARIANT - UPDATE
|--------------------------------------------------------------------------
*/
test('guest cannot update variant', function () {
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->put(route('admin.master.products.variants.update', [$product, $variant]), [
'name' => 'Updated',
'stock' => 50,
'reject_stock' => 5,
'retail_stock' => 10,
'photo_key' => 'product-variant/updated.jpg',
'prices' => allPriceTypes(),
]);
$response->assertRedirect(route('login'));
});
test('variant can be updated', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create([
'name' => 'Original Name',
'stock' => 100,
]);
$response = $this->put(route('admin.master.products.variants.update', [$product, $variant]), [
'name' => 'Updated Name',
'stock' => 200,
'reject_stock' => 15,
'retail_stock' => 25,
'photo_key' => 'product-variant/new-photo.jpg',
'prices' => allPriceTypes(),
]);
$response->assertRedirect();
$this->assertDatabaseHas('product_variants', [
'id' => $variant->id,
'name' => 'Updated Name',
'stock' => 200,
'reject_stock' => 15,
'retail_stock' => 25,
]);
});
test('variant update replaces old prices', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
ProductPrice::create([
'variant_id' => $variant->id,
'type' => 'retail',
'price' => 10000,
]);
$this->assertDatabaseCount('product_prices', 1);
$response = $this->put(route('admin.master.products.variants.update', [$product, $variant]), [
'name' => $variant->name,
'stock' => $variant->stock,
'reject_stock' => $variant->reject_stock,
'retail_stock' => $variant->retail_stock,
'photo_key' => 'product-variant/test.jpg',
'prices' => allPriceTypes(),
]);
$response->assertRedirect();
$this->assertDatabaseCount('product_prices', 9);
});
test('variant update name is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->put(route('admin.master.products.variants.update', [$product, $variant]), [
'name' => '',
'stock' => 100,
'reject_stock' => 10,
'retail_stock' => 20,
'photo_key' => 'product-variant/test.jpg',
'prices' => allPriceTypes(),
]);
$response->assertSessionHasErrors('name');
});
test('variant update requires exactly 9 prices', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->put(route('admin.master.products.variants.update', [$product, $variant]), [
'name' => 'Test',
'stock' => 100,
'reject_stock' => 10,
'retail_stock' => 20,
'photo_key' => 'product-variant/test.jpg',
'prices' => [
['type' => 'retail', 'price' => 10000],
],
]);
$response->assertSessionHasErrors('prices');
});
/*
|--------------------------------------------------------------------------
| PRODUCT VARIANT - DELETE
|--------------------------------------------------------------------------
*/
test('guest cannot delete variant', function () {
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->delete(route('admin.master.products.variants.destroy', [$product, $variant]));
$response->assertRedirect(route('login'));
$this->assertDatabaseHas('product_variants', ['id' => $variant->id, 'deleted_at' => null]);
});
test('variant can be deleted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->delete(route('admin.master.products.variants.destroy', [$product, $variant]));
$response->assertRedirect();
$this->assertSoftDeleted('product_variants', ['id' => $variant->id]);
});
test('delete variant cascades to product prices', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
ProductPrice::create([
'variant_id' => $variant->id,
'type' => 'retail',
'price' => 10000,
]);
ProductPrice::create([
'variant_id' => $variant->id,
'type' => 'wholesale',
'price' => 8000,
]);
$this->assertDatabaseCount('product_prices', 2);
$response = $this->delete(route('admin.master.products.variants.destroy', [$product, $variant]));
$response->assertRedirect();
$this->assertDatabaseCount('product_prices', 0);
});
test('delete variant does not affect other variants', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant1 = ProductVariant::factory()->for($product)->create(['name' => 'Keep']);
$variant2 = ProductVariant::factory()->for($product)->create(['name' => 'Delete']);
ProductPrice::create([
'variant_id' => $variant1->id,
'type' => 'retail',
'price' => 10000,
]);
ProductPrice::create([
'variant_id' => $variant2->id,
'type' => 'retail',
'price' => 15000,
]);
$response = $this->delete(route('admin.master.products.variants.destroy', [$product, $variant2]));
$response->assertRedirect();
$this->assertDatabaseHas('product_variants', ['id' => $variant1->id, 'name' => 'Keep', 'deleted_at' => null]);
$this->assertDatabaseHas('product_prices', ['variant_id' => $variant1->id]);
});
test('deleting non-existent variant returns 404', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$response = $this->delete(route('admin.master.products.variants.destroy', [$product, 99999]));
$response->assertStatus(404);
});

View File

@ -15,7 +15,7 @@
use App\Services\Admin\Finance\PayrollPeriodService;
use App\Services\Admin\HR\AttendanceService;
use App\Services\Admin\HR\LeaveRequestService;
use App\Services\Admin\Master\ProductService;
use App\Services\Admin\Master\Product\ProductService;
use App\Services\NotificationService;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;