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:
parent
97f114136b
commit
e3c0f14c43
@ -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']),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
129
app/Services/Admin/Master/Product/ProductVariantService.php
Normal file
129
app/Services/Admin/Master/Product/ProductVariantService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
235
resources/js/components/card-table.tsx
Normal file
235
resources/js/components/card-table.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
48
resources/js/components/hooks/use-card-table-expand.ts
Normal file
48
resources/js/components/hooks/use-card-table-expand.ts
Normal 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 };
|
||||
@ -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 && (
|
||||
|
||||
@ -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 [
|
||||
{
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
197
resources/js/pages/admin/master/product/product-card.tsx
Normal file
197
resources/js/pages/admin/master/product/product-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
271
resources/js/pages/admin/master/product/variant/edit.tsx
Normal file
271
resources/js/pages/admin/master/product/variant/edit.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
202
resources/js/pages/admin/master/product/variant/sub-row.tsx
Normal file
202
resources/js/pages/admin/master/product/variant/sub-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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']);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user