Refactor code structure for improved readability and maintainability
This commit is contained in:
parent
c4db043fda
commit
dc8ecf6c52
@ -8,6 +8,28 @@ enum PriceType: string
|
||||
{
|
||||
use HasValues;
|
||||
|
||||
case RETAIL = 'retail';
|
||||
case DISTRIBUTOR = 'distributor';
|
||||
case AGEN = 'agent';
|
||||
case SUB_AGEN = 'sub_agent';
|
||||
case WHOLESALE = 'wholesale';
|
||||
case RETAIL = 'retail';
|
||||
case TIKTOK = 'tiktok';
|
||||
case SHOPEE = 'shopee';
|
||||
case CAPITAL = 'capital';
|
||||
case REJECT = 'reject';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DISTRIBUTOR => 'Distributor',
|
||||
self::AGEN => 'Agen',
|
||||
self::SUB_AGEN => 'Sub Agen',
|
||||
self::WHOLESALE => 'Grosir',
|
||||
self::RETAIL => 'Ecer',
|
||||
self::TIKTOK => 'TikTok',
|
||||
self::SHOPEE => 'Shopee',
|
||||
self::CAPITAL => 'Modal',
|
||||
self::REJECT => 'Reject',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,4 +11,13 @@ enum ProductStatus: string
|
||||
case ACTIVE = 'active';
|
||||
case INACTIVE = 'inactive';
|
||||
case DRAFT = 'draft';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ACTIVE => 'Aktif',
|
||||
self::INACTIVE => 'Non Aktif',
|
||||
self::DRAFT => 'Draft',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
85
app/Http/Controllers/Admin/Master/ProductController.php
Normal file
85
app/Http/Controllers/Admin/Master/ProductController.php
Normal file
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Master;
|
||||
|
||||
use App\Enums\ProductStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Master\ProductRequest;
|
||||
use App\Models\Product;
|
||||
use App\Services\Admin\Master\CategoryService;
|
||||
use App\Services\Admin\Master\ProductService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ProductService $service,
|
||||
private CategoryService $categoryService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return Inertia::render('admin/master/product/index', [
|
||||
'products' => $this->service->getAll($request->only(['status', 'name'])),
|
||||
'filters' => $request->only(['status', 'name']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('admin/master/product/create', [
|
||||
'categories' => $this->categoryService->getAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(ProductRequest $request): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn() => $this->service->create($request->validated()),
|
||||
'Produk berhasil ditambahkan.',
|
||||
'admin.master.products.index',
|
||||
'admin.master.products.create'
|
||||
);
|
||||
}
|
||||
|
||||
public function edit(Product $product): Response
|
||||
{
|
||||
return Inertia::render('admin/master/product/edit', [
|
||||
'product' => $this->service->getForEdit($product),
|
||||
'categories' => $this->categoryService->getAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(ProductRequest $request, Product $product): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn() => $this->service->update($product, $request->validated()),
|
||||
'Produk berhasil diperbarui.',
|
||||
'admin.master.products.index',
|
||||
'admin.master.products.edit',
|
||||
['product' => $product]
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Product $product): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn() => $this->service->delete($product),
|
||||
'Produk berhasil dihapus.',
|
||||
'admin.master.products.index'
|
||||
);
|
||||
}
|
||||
|
||||
public function toggleStatus(Product $product): RedirectResponse
|
||||
{
|
||||
$this->service->toggleStatus($product);
|
||||
$status = $product->fresh()->status;
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => "Status produk berhasil diubah menjadi {$status->label()}."]);
|
||||
|
||||
return to_route('admin.master.products.index');
|
||||
}
|
||||
}
|
||||
@ -30,8 +30,8 @@ public function store(PresignedUrlRequest $request): JsonResponse
|
||||
public function show(string $key): RedirectResponse
|
||||
{
|
||||
$minutes = (int) request('minutes', 60);
|
||||
$url = $this->service->getTemporaryUrl(urldecode($key), $minutes);
|
||||
$url = $this->service->getTemporaryUrl($key, $minutes);
|
||||
|
||||
return redirect($url);
|
||||
return redirect()->away($url);
|
||||
}
|
||||
}
|
||||
|
||||
93
app/Http/Requests/Admin/Master/ProductRequest.php
Normal file
93
app/Http/Requests/Admin/Master/ProductRequest.php
Normal file
@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Master;
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Override;
|
||||
|
||||
class ProductRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function prepareForValidation()
|
||||
{
|
||||
$sharedPrices = $this->shared_prices;
|
||||
if (is_array($sharedPrices)) {
|
||||
foreach ($sharedPrices as $i => $price) {
|
||||
if (isset($price['price']) && is_string($price['price'])) {
|
||||
$this->request->set("shared_prices.$i.price", (int) str_replace('.', '', $price['price']));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$variants = $this->variants;
|
||||
if (is_array($variants)) {
|
||||
foreach ($variants as $i => $variant) {
|
||||
if (isset($variant['prices']) && is_array($variant['prices'])) {
|
||||
foreach ($variant['prices'] as $j => $price) {
|
||||
if (isset($price['price']) && is_string($price['price'])) {
|
||||
$this->request->set("variants.$i.prices.$j.price", (int) str_replace('.', '', $price['price']));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$useSamePrice = $this->boolean('use_same_price');
|
||||
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:200',
|
||||
],
|
||||
'description' => ['nullable', 'string'],
|
||||
'status' => ['nullable', Rule::in(['active', 'inactive', 'draft'])],
|
||||
'category_ids' => ['required', 'array', 'min:1'],
|
||||
'category_ids.*' => ['exists:categories,id'],
|
||||
'use_same_price' => ['nullable', 'boolean'],
|
||||
'shared_prices' => ['required_if:use_same_price,true', 'nullable', 'array', ...($useSamePrice ? ['size:9'] : [])],
|
||||
'shared_prices.*.type' => ['required_if:use_same_price,true', 'nullable', Rule::in(PriceType::values())],
|
||||
'shared_prices.*.price' => ['required_if:use_same_price,true', 'nullable', 'integer', 'min:0'],
|
||||
'variants' => ['required', 'array', 'min:1'],
|
||||
'variants.*.id' => ['nullable', 'integer'],
|
||||
'variants.*.name' => ['required', 'string', 'max:200'],
|
||||
'variants.*.stock' => ['required', 'integer', 'min:0'],
|
||||
'variants.*.reject_stock' => ['required', 'integer', 'min:0'],
|
||||
'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
|
||||
'variants.*.photo_key' => ['required', 'string', 'max:500'],
|
||||
'variants.*.prices' => ['required_if:use_same_price,false', 'nullable', 'array', ...(!$useSamePrice ? ['size:9'] : [])],
|
||||
'variants.*.prices.*.id' => ['nullable', 'integer'],
|
||||
'variants.*.prices.*.type' => ['required_if:use_same_price,false', 'nullable', Rule::in(PriceType::values())],
|
||||
'variants.*.prices.*.price' => ['required_if:use_same_price,false', 'nullable', 'integer', 'min:0'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Nama Produk',
|
||||
'description' => 'Deskripsi',
|
||||
'status' => 'Status',
|
||||
'category_ids' => 'Kategori',
|
||||
'variants' => 'Varian',
|
||||
'variants.*.name' => 'Nama Varian',
|
||||
'variants.*.stock' => 'Stok',
|
||||
'variants.*.reject_stock' => 'Stok Reject',
|
||||
'variants.*.retail_stock' => 'Stok Retail',
|
||||
'variants.*.photo_key' => 'Foto',
|
||||
'variants.*.prices' => 'Harga',
|
||||
'variants.*.prices.*.type' => 'Tipe Harga',
|
||||
'variants.*.prices.*.price' => 'Harga',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -11,8 +11,10 @@
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Sluggable\Attributes\Sluggable;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Sluggable(from: 'name', to: 'slug')]
|
||||
class Product extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
@ -3,19 +3,14 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class ProductCategory extends Model
|
||||
class ProductCategory extends Pivot
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = ['product_id', 'category_id'];
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
|
||||
@ -3,18 +3,21 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends(['type_label'])]
|
||||
class ProductPrice extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use HasFactory;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
@ -40,4 +43,11 @@ public function variant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductVariant::class, 'variant_id');
|
||||
}
|
||||
|
||||
protected function typeLabel(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->type->label(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,11 +8,13 @@
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class ProductVariant extends Model
|
||||
class ProductVariant extends Model implements HasMedia
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use HasFactory, SoftDeletes, InteractsWithMedia;
|
||||
|
||||
public function orderItems(): HasMany
|
||||
{
|
||||
|
||||
235
app/Services/Admin/Master/ProductService.php
Normal file
235
app/Services/Admin/Master/ProductService.php
Normal file
@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Master;
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\ProductPrice;
|
||||
use App\Services\S3PresignedService;
|
||||
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,
|
||||
) {}
|
||||
|
||||
public function getAll(array $filters = []): Collection
|
||||
{
|
||||
$products = Product::select('id', 'name', 'slug', 'description', 'status')
|
||||
->with([
|
||||
'categories:id,name',
|
||||
'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
|
||||
'productVariants.productPrices:id,variant_id,type,price',
|
||||
'productVariants.media',
|
||||
])
|
||||
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
|
||||
->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%"))
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
$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;
|
||||
});
|
||||
});
|
||||
|
||||
return $products;
|
||||
}
|
||||
|
||||
public function create(array $data): Product
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$product = Product::create([
|
||||
'name' => $data['name'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'status' => $data['status'] ?? 'active',
|
||||
]);
|
||||
|
||||
$product->categories()->sync($data['category_ids']);
|
||||
|
||||
$useSamePrice = $data['use_same_price'] ?? false;
|
||||
|
||||
foreach ($data['variants'] as $index => $variantData) {
|
||||
$variant = $product->productVariants()->create([
|
||||
'name' => $variantData['name'],
|
||||
'stock' => $variantData['stock'],
|
||||
'reject_stock' => $variantData['reject_stock'],
|
||||
'retail_stock' => $variantData['retail_stock'],
|
||||
]);
|
||||
|
||||
$prices = $useSamePrice
|
||||
? $data['shared_prices']
|
||||
: $variantData['prices'];
|
||||
|
||||
foreach ($prices as $priceData) {
|
||||
ProductPrice::create([
|
||||
'variant_id' => $variant->id,
|
||||
'type' => $priceData['type'],
|
||||
'price' => $priceData['price'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! empty($variantData['photo_key'])) {
|
||||
$this->registerPhotos($variant, [$variantData['photo_key']]);
|
||||
}
|
||||
}
|
||||
|
||||
return $product;
|
||||
});
|
||||
}
|
||||
|
||||
public function getForEdit(Product $product): array
|
||||
{
|
||||
$product->load([
|
||||
'categories:id,name',
|
||||
'productVariants.productPrices',
|
||||
'productVariants.media',
|
||||
]);
|
||||
|
||||
$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,
|
||||
'prices' => $variant->productPrices->map(fn ($p) => [
|
||||
'type' => $p->type->value,
|
||||
'price' => $p->price,
|
||||
]),
|
||||
];
|
||||
});
|
||||
|
||||
return [
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'description' => $product->description,
|
||||
'status' => $product->status->value,
|
||||
'category_ids' => $product->categories->pluck('id'),
|
||||
'product_variants' => $variants,
|
||||
];
|
||||
}
|
||||
|
||||
public function update(Product $product, array $data): Product
|
||||
{
|
||||
return DB::transaction(function () use ($product, $data) {
|
||||
$product->update([
|
||||
'name' => $data['name'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'status' => $data['status'] ?? $product->status,
|
||||
]);
|
||||
|
||||
$product->categories()->sync($data['category_ids']);
|
||||
|
||||
$useSamePrice = $data['use_same_price'] ?? false;
|
||||
|
||||
$existingVariantIds = collect($data['variants'])
|
||||
->pluck('id')
|
||||
->filter()
|
||||
->toArray();
|
||||
|
||||
$product->productVariants()
|
||||
->whereNotIn('id', $existingVariantIds)
|
||||
->each(function (ProductVariant $variant) {
|
||||
$variant->productPrices()->delete();
|
||||
$variant->clearMediaCollection('photos');
|
||||
$variant->delete();
|
||||
});
|
||||
|
||||
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'],
|
||||
]);
|
||||
|
||||
$variant->productPrices()->delete();
|
||||
|
||||
$prices = $useSamePrice
|
||||
? $data['shared_prices']
|
||||
: $variantData['prices'];
|
||||
|
||||
foreach ($prices as $priceData) {
|
||||
ProductPrice::create([
|
||||
'variant_id' => $variant->id,
|
||||
'type' => $priceData['type'],
|
||||
'price' => $priceData['price'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! empty($variantData['photo_key'])) {
|
||||
$variant->clearMediaCollection('photos');
|
||||
$this->registerPhotos($variant, [$variantData['photo_key']]);
|
||||
}
|
||||
}
|
||||
|
||||
return $product;
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(Product $product): bool
|
||||
{
|
||||
return DB::transaction(function () use ($product) {
|
||||
$product->productVariants->each(function (ProductVariant $variant) {
|
||||
$variant->productPrices()->delete();
|
||||
$variant->clearMediaCollection('photos');
|
||||
$variant->delete();
|
||||
});
|
||||
|
||||
$product->categories()->detach();
|
||||
|
||||
return $product->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function toggleStatus(Product $product): void
|
||||
{
|
||||
$product->update([
|
||||
'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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -19,7 +19,6 @@ public function up(): void
|
||||
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->unique(['variant_id', 'type']);
|
||||
$table->index('variant_id');
|
||||
|
||||
@ -14,17 +14,15 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
|
||||
|
||||
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable
|
||||
} from '@tanstack/react-table';
|
||||
import type { ColumnDef, ColumnFiltersState, SortingState } from '@tanstack/react-table';
|
||||
import type { ColumnDef, ColumnFiltersState, ExpandedState, SortingState, Row } from '@tanstack/react-table';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
@ -50,6 +48,8 @@ interface DataTableProps<TData, TValue> {
|
||||
onReorder?: (items: TData[]) => void;
|
||||
getRowId?: (item: TData) => string | number;
|
||||
toolbar?: React.ReactNode;
|
||||
renderSubRow?: (row: Row<TData>, searchValue?: string) => React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
const DragHandleContext = React.createContext<{
|
||||
@ -117,10 +117,20 @@ export function DataTable<TData, TValue>({
|
||||
onReorder,
|
||||
getRowId,
|
||||
toolbar,
|
||||
renderSubRow,
|
||||
defaultExpanded = false,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] =
|
||||
React.useState<ColumnFiltersState>([]);
|
||||
const [expanded, setExpanded] = React.useState<ExpandedState>(() => {
|
||||
if (!defaultExpanded || !data.length) return {};
|
||||
const initial: Record<string, boolean> = {};
|
||||
data.forEach((item, index) => {
|
||||
initial[String(index)] = true;
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
|
||||
const isSortable = !!onReorder && !!getRowId;
|
||||
|
||||
@ -151,6 +161,8 @@ export function DataTable<TData, TValue>({
|
||||
columns: visibleColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getExpandedRowModel: renderSubRow ? getExpandedRowModel() : undefined,
|
||||
onExpandedChange: setExpanded,
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
@ -158,6 +170,7 @@ export function DataTable<TData, TValue>({
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
expanded,
|
||||
},
|
||||
});
|
||||
|
||||
@ -300,38 +313,51 @@ export function DataTable<TData, TValue>({
|
||||
table
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
'selected'
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={
|
||||
(
|
||||
cell
|
||||
.column
|
||||
<React.Fragment key={row.id}>
|
||||
<TableRow
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
'selected'
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={
|
||||
(
|
||||
cell
|
||||
.column
|
||||
.columnDef
|
||||
.meta as {
|
||||
className?: string;
|
||||
}
|
||||
)?.className
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef
|
||||
.meta as {
|
||||
className?: string;
|
||||
}
|
||||
)?.className
|
||||
}
|
||||
.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{renderSubRow && row.getIsExpanded() && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={visibleColumns.length}
|
||||
className="bg-muted/50 p-0"
|
||||
>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
<div className="p-4">
|
||||
{renderSubRow(row, (table.getColumn(searchKey ?? '')?.getFilterValue() as string) ?? '')}
|
||||
</div>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
)
|
||||
) : (
|
||||
|
||||
@ -17,6 +17,7 @@ type FileUploadProps = {
|
||||
onChange: (key: string | null) => void;
|
||||
folder?: string;
|
||||
accept?: string;
|
||||
maxSize?: number;
|
||||
onUploadingChange?: (uploading: boolean) => void;
|
||||
existingUrl?: string | null;
|
||||
onFileMeta?: (meta: { size: number; type: string } | null) => void;
|
||||
@ -34,7 +35,32 @@ function formatFileSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image/png,image/webp,image/gif', onUploadingChange, existingUrl, onFileMeta }: FileUploadProps) {
|
||||
function acceptToLabels(accept: string): string[] {
|
||||
const mimeMap: Record<string, string> = {
|
||||
'image/jpeg': 'JPG',
|
||||
'image/png': 'PNG',
|
||||
'image/webp': 'WebP',
|
||||
'image/gif': 'GIF',
|
||||
'image/svg+xml': 'SVG',
|
||||
'application/pdf': 'PDF',
|
||||
'video/mp4': 'MP4',
|
||||
'application/zip': 'ZIP',
|
||||
};
|
||||
|
||||
return accept
|
||||
.split(',')
|
||||
.map((mime) => mimeMap[mime.trim()] || mime.trim().split('/').pop()?.toUpperCase() || 'File')
|
||||
.filter((v, i, a) => a.indexOf(v) === i);
|
||||
}
|
||||
|
||||
function formatMaxSize(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(0)}KB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
|
||||
}
|
||||
|
||||
export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image/png,image/webp,image/gif', maxSize = 10 * 1024 * 1024, onUploadingChange, existingUrl, onFileMeta }: FileUploadProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const uploadId = useId();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@ -43,9 +69,12 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
|
||||
const [fileSize, setFileSize] = useState<number | null>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
|
||||
const onUploadingChangeRef = useRef(onUploadingChange);
|
||||
onUploadingChangeRef.current = onUploadingChange;
|
||||
|
||||
useEffect(() => {
|
||||
onUploadingChange?.(uploading);
|
||||
}, [uploading, onUploadingChange]);
|
||||
onUploadingChangeRef.current?.(uploading);
|
||||
}, [uploading]);
|
||||
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
@ -149,7 +178,9 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
|
||||
) : (
|
||||
<>
|
||||
<AttachmentTitle>Unggah File</AttachmentTitle>
|
||||
<AttachmentDescription>Opsional · JPG, PNG, WebP, GIF · Maks 10MB</AttachmentDescription>
|
||||
<AttachmentDescription>
|
||||
{`${acceptToLabels(accept).join(', ')} · Maks ${formatMaxSize(maxSize)}`}
|
||||
</AttachmentDescription>
|
||||
</>
|
||||
)}
|
||||
</AttachmentContent>
|
||||
|
||||
@ -9,6 +9,8 @@ import {
|
||||
type RupiahInputProps = {
|
||||
name?: string;
|
||||
defaultValue?: number;
|
||||
value?: number;
|
||||
onValueChange?: (value: number) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
min?: number;
|
||||
@ -29,14 +31,25 @@ function parseRupiah(value: string): number {
|
||||
export function RupiahInput({
|
||||
name,
|
||||
defaultValue = 0,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = '0',
|
||||
disabled = false,
|
||||
min,
|
||||
max,
|
||||
className,
|
||||
}: RupiahInputProps) {
|
||||
const [displayValue, setDisplayValue] = useState(formatRupiah(defaultValue));
|
||||
const lastValidRef = useRef(defaultValue);
|
||||
const isControlled = value !== undefined;
|
||||
const [displayValue, setDisplayValue] = useState(formatRupiah(isControlled ? value : defaultValue));
|
||||
const lastValidRef = useRef(isControlled ? value : defaultValue);
|
||||
|
||||
if (isControlled) {
|
||||
const formatted = formatRupiah(value);
|
||||
if (formatted !== displayValue) {
|
||||
setDisplayValue(formatted);
|
||||
lastValidRef.current = value;
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@ -53,8 +66,9 @@ export function RupiahInput({
|
||||
|
||||
lastValidRef.current = clamped;
|
||||
setDisplayValue(formatRupiah(clamped));
|
||||
onValueChange?.(clamped);
|
||||
},
|
||||
[min, max],
|
||||
[min, max, onValueChange],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
|
||||
@ -11,7 +11,7 @@ export default function AppSidebarLayout({
|
||||
return (
|
||||
<AppShell variant="sidebar">
|
||||
<AppSidebar />
|
||||
<AppContent variant="sidebar" className="overflow-x-hidden">
|
||||
<AppContent variant="sidebar" className="min-h-svh overflow-x-hidden overflow-y-auto">
|
||||
<AppSidebarHeader breadcrumbs={breadcrumbs} />
|
||||
{children}
|
||||
</AppContent>
|
||||
|
||||
@ -73,7 +73,5 @@ export async function uploadFile(file: File, folder?: string): Promise<string> {
|
||||
}
|
||||
|
||||
export function getTemporaryUrl(key: string, minutes = 60): string {
|
||||
// This is a client-side helper - the actual presigned GET URL
|
||||
// should be generated server-side via the Expense model accessor
|
||||
return `/api/presigned-url/${encodeURIComponent(key)}?minutes=${minutes}`;
|
||||
}
|
||||
|
||||
364
resources/js/pages/admin/master/product/columns.tsx
Normal file
364
resources/js/pages/admin/master/product/columns.tsx
Normal file
@ -0,0 +1,364 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { ArrowUpDown, ChevronRight, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
export type ProductVariant = {
|
||||
id: number;
|
||||
name: string;
|
||||
stock: number;
|
||||
reject_stock: number;
|
||||
retail_stock: number;
|
||||
photo_url: string | null;
|
||||
product_prices: {
|
||||
id: number;
|
||||
type: string;
|
||||
type_label: string;
|
||||
price: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
categories: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
product_variants: ProductVariant[];
|
||||
};
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
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 getFilteredVariants(allVariants: ProductVariant[], searchValue: string): ProductVariant[] {
|
||||
const query = searchValue.toLowerCase().trim();
|
||||
return query
|
||||
? allVariants.filter((v) => v.name.toLowerCase().includes(query))
|
||||
: allVariants;
|
||||
}
|
||||
|
||||
type CreateColumnsParams = {
|
||||
handleEdit: (product: Product) => void;
|
||||
handleDeleteClick: (product: Product) => void;
|
||||
toggleStatusUrl: (id: number) => string;
|
||||
};
|
||||
|
||||
export function createProductColumns(
|
||||
params: CreateColumnsParams,
|
||||
): ColumnDef<Product>[] {
|
||||
const { handleEdit, handleDeleteClick, toggleStatusUrl } = params;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'expand',
|
||||
header: '',
|
||||
cell: ({ row }) => {
|
||||
const hasVariants = (row.original.product_variants?.length ?? 0) > 0;
|
||||
|
||||
if (!hasVariants) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => row.toggleExpanded()}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 transition-transform ${row.getIsExpanded() ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
className: 'w-[40px]',
|
||||
headerClassName: 'w-[40px]',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'variant_names',
|
||||
accessorFn: (row) => row.product_variants?.map((v) => v.name).join(' ') ?? '',
|
||||
header: () => null,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
className: 'hidden',
|
||||
headerClassName: 'hidden',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
headerClassName: 'w-[50px] text-center',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>Nama Produk</span>
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const product = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{product.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{product.categories?.map((c) => c.name).join(', ') || '-'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'variants',
|
||||
header: () => <span>Varian</span>,
|
||||
cell: ({ row, table }) => {
|
||||
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? '';
|
||||
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue);
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
|
||||
{variants.length} varian
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'stock',
|
||||
header: () => <span className="block text-center">Stok Bagus</span>,
|
||||
meta: {
|
||||
className: 'w-[80px] text-center',
|
||||
headerClassName: 'w-[80px] text-center',
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? '';
|
||||
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue);
|
||||
const totalStock = variants.reduce((sum, v) => sum + (v.stock ?? 0), 0);
|
||||
|
||||
return (
|
||||
<span className="block text-center font-medium">
|
||||
{formatNumber(totalStock)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'reject_stock',
|
||||
header: () => <span className="block text-center">Stok Reject</span>,
|
||||
meta: {
|
||||
className: 'w-[80px] text-center',
|
||||
headerClassName: 'w-[80px] text-center',
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? '';
|
||||
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue);
|
||||
const totalReject = variants.reduce((sum, v) => sum + (v.reject_stock ?? 0), 0);
|
||||
|
||||
return (
|
||||
<span className="block text-center font-medium">
|
||||
{formatNumber(totalReject)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'retail_stock',
|
||||
header: () => <span className="block text-center">Stok Ecer</span>,
|
||||
meta: {
|
||||
className: 'w-[80px] text-center',
|
||||
headerClassName: 'w-[80px] text-center',
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? '';
|
||||
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue);
|
||||
const totalRetail = variants.reduce((sum, v) => sum + (v.retail_stock ?? 0), 0);
|
||||
|
||||
return (
|
||||
<span className="block text-center font-medium">
|
||||
{formatNumber(totalRetail)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'total_stock',
|
||||
header: () => <span className="block text-center">Total Stok</span>,
|
||||
meta: {
|
||||
className: 'w-[80px] text-center',
|
||||
headerClassName: 'w-[80px] text-center',
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? '';
|
||||
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue);
|
||||
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 total = totalStock + totalReject + totalRetail;
|
||||
|
||||
return (
|
||||
<span className="block text-center font-medium">
|
||||
{formatNumber(total)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>Status</span>
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const product = row.original;
|
||||
const isToggleable = product.status === 'active' || product.status === 'inactive';
|
||||
const isChecked = product.status === 'active';
|
||||
|
||||
function handleToggle(checked: boolean) {
|
||||
router.post(toggleStatusUrl(product.id), {}, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isToggleable) {
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${getStatusVariant(product.status)}`}>
|
||||
{getStatusLabel(product.status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="block text-center">Aksi</span>,
|
||||
meta: {
|
||||
className: 'w-[100px] text-center',
|
||||
headerClassName: 'w-[100px] text-center',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const product = row.original;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(product)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Edit
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteClick(product)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
474
resources/js/pages/admin/master/product/create.tsx
Normal file
474
resources/js/pages/admin/master/product/create.tsx
Normal file
@ -0,0 +1,474 @@
|
||||
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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { index as productIndex, store } from '@/routes/admin/master/products';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { ArrowLeft, Copy, ClipboardPaste, Check, Plus, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
type Category = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
categories: Category[];
|
||||
};
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
function createEmptyPrices(): Array<{ type: string; price: number }> {
|
||||
return PRICE_TYPES.map((pt) => ({ type: pt.key, price: 0 }));
|
||||
}
|
||||
|
||||
type VariantState = {
|
||||
name: string;
|
||||
stock: number;
|
||||
reject_stock: number;
|
||||
retail_stock: number;
|
||||
photo: string | null;
|
||||
uploading: boolean;
|
||||
prices: Array<{ type: string; price: number }>;
|
||||
};
|
||||
|
||||
export default function ProductCreate({ categories }: Props) {
|
||||
const [categoryIds, setCategoryIds] = useState<number[]>([]);
|
||||
const [useSamePrice, setUseSamePrice] = useState(true);
|
||||
const [sharedPrices, setSharedPrices] = useState<Array<{ type: string; price: number }>>(createEmptyPrices());
|
||||
const [variants, setVariants] = useState<VariantState[]>([
|
||||
{
|
||||
name: '',
|
||||
stock: 0,
|
||||
reject_stock: 0,
|
||||
retail_stock: 0,
|
||||
photo: null,
|
||||
uploading: false,
|
||||
prices: createEmptyPrices(),
|
||||
},
|
||||
]);
|
||||
|
||||
const variantsRef = useRef(variants);
|
||||
variantsRef.current = variants;
|
||||
|
||||
const addVariant = useCallback(() => {
|
||||
setVariants((prev) => [
|
||||
...prev,
|
||||
{
|
||||
name: '',
|
||||
stock: 0,
|
||||
reject_stock: 0,
|
||||
retail_stock: 0,
|
||||
photo: null,
|
||||
uploading: false,
|
||||
prices: createEmptyPrices(),
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const removeVariant = useCallback((index: number) => {
|
||||
setVariants((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const updateVariant = useCallback((index: number, field: keyof VariantState, value: unknown) => {
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
(updated[index] as Record<string, unknown>)[field] = value;
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateVariantPrice = useCallback((variantIndex: number, priceIndex: number, value: number) => {
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[variantIndex] = {
|
||||
...updated[variantIndex],
|
||||
prices: updated[variantIndex].prices.map((p, i) =>
|
||||
i === priceIndex ? { ...p, price: value } : p
|
||||
),
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSharedPrice = useCallback((priceIndex: number, value: number) => {
|
||||
setSharedPrices((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[priceIndex] = { ...updated[priceIndex], price: value };
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
|
||||
const copyPrices = useCallback((variantIndex: number) => {
|
||||
setVariants((prev) => {
|
||||
const prices = prev[variantIndex].prices;
|
||||
navigator.clipboard.writeText(JSON.stringify(prices));
|
||||
setCopiedIndex(variantIndex);
|
||||
setTimeout(() => setCopiedIndex(null), 1500);
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pastePrices = useCallback((variantIndex: number) => {
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
try {
|
||||
const prices = JSON.parse(text) as Array<{ type: string; price: number }>;
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[variantIndex] = { ...updated[variantIndex], prices };
|
||||
return updated;
|
||||
});
|
||||
} catch {
|
||||
// invalid clipboard data
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyToAll = useCallback((variantIndex: number) => {
|
||||
setVariants((prev) => {
|
||||
const sourcePrices = prev[variantIndex].prices;
|
||||
return prev.map((v, i) =>
|
||||
i === variantIndex ? v : { ...v, prices: [...sourcePrices] }
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
function getPayload() {
|
||||
return {
|
||||
category_ids: categoryIds,
|
||||
use_same_price: useSamePrice,
|
||||
shared_prices: useSamePrice ? sharedPrices.map((p) => ({
|
||||
type: p.type,
|
||||
price: Number(p.price),
|
||||
})) : [],
|
||||
variants: variantsRef.current.map((v) => ({
|
||||
name: v.name,
|
||||
stock: Number(v.stock),
|
||||
reject_stock: Number(v.reject_stock),
|
||||
retail_stock: Number(v.retail_stock),
|
||||
photo_key: v.photo,
|
||||
prices: useSamePrice
|
||||
? []
|
||||
: v.prices.map((p) => ({ type: p.type, price: Number(p.price) })),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Tambah Produk" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Tambah Produk</h2>
|
||||
<Button asChild variant="outline">
|
||||
<a href={productIndex.url()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Kembali
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
action={store()}
|
||||
transform={(data) => ({
|
||||
...data,
|
||||
...getPayload(),
|
||||
})}
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<>
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informasi Produk</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama Produk <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama produk"
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Status <span className="text-destructive">*</span></Label>
|
||||
<RadioGroup name="status" defaultValue="active" className="flex gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="active" id="status-active" />
|
||||
<Label htmlFor="status-active" className="font-normal">Aktif</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="inactive" id="status-inactive" />
|
||||
<Label htmlFor="status-inactive" className="font-normal">Non Aktif</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="draft" id="status-draft" />
|
||||
<Label htmlFor="status-draft" className="font-normal">Draft</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<InputError message={errors.status} />
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label>Kategori <span className="text-destructive">*</span></Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((category) => (
|
||||
<label
|
||||
key={category.id}
|
||||
className="flex items-center space-x-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="category_ids[]"
|
||||
value={category.id}
|
||||
checked={categoryIds.includes(category.id)}
|
||||
onChange={(e) => {
|
||||
setCategoryIds((prev) =>
|
||||
e.target.checked
|
||||
? [...prev, category.id]
|
||||
: prev.filter((id) => id !== category.id)
|
||||
);
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>{category.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<InputError message={errors.category_ids} />
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="description">Deskripsi</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Masukkan deskripsi produk"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Harga</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<RadioGroup
|
||||
name="use_same_price"
|
||||
value={useSamePrice ? '1' : '0'}
|
||||
onValueChange={(val) => setUseSamePrice(val === '1')}
|
||||
className="flex gap-6"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="1" id="price-same" />
|
||||
<Label htmlFor="price-same" className="font-normal">Semua varian sama</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="0" id="price-different" />
|
||||
<Label htmlFor="price-different" className="font-normal">Harga per varian</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{useSamePrice && (
|
||||
<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={sharedPrices[priceIndex]?.price ?? 0}
|
||||
onValueChange={(val) => updateSharedPrice(priceIndex, val)}
|
||||
/>
|
||||
<InputError message={errors[`shared_prices.${priceIndex}.price`]} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Varian Produk</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{variants.map((variant, variantIndex) => (
|
||||
<div key={variantIndex} className="rounded-lg border p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">Varian {variantIndex + 1}</h4>
|
||||
<div className="flex items-center gap-1">
|
||||
{!useSamePrice && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyPrices(variantIndex)}
|
||||
>
|
||||
{copiedIndex === variantIndex ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
Salin
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pastePrices(variantIndex)}
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
Tempel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => applyToAll(variantIndex)}
|
||||
>
|
||||
Terapkan ke Semua
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{variantIndex > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => removeVariant(variantIndex)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div 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={variant.name}
|
||||
onChange={(e) => updateVariant(variantIndex, 'name', e.target.value)}
|
||||
placeholder="Contoh: Ukuran L, Warna Merah"
|
||||
/>
|
||||
<InputError message={errors[`variants.${variantIndex}.name`]} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Stok Bagus <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={variant.stock}
|
||||
onChange={(e) => updateVariant(variantIndex, 'stock', Number(e.target.value))}
|
||||
/>
|
||||
<InputError message={errors[`variants.${variantIndex}.stock`]} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Stok Reject <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={variant.reject_stock}
|
||||
onChange={(e) => updateVariant(variantIndex, 'reject_stock', Number(e.target.value))}
|
||||
/>
|
||||
<InputError message={errors[`variants.${variantIndex}.reject_stock`]} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Stok Ecer <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={variant.retail_stock}
|
||||
onChange={(e) => updateVariant(variantIndex, 'retail_stock', Number(e.target.value))}
|
||||
/>
|
||||
<InputError message={errors[`variants.${variantIndex}.retail_stock`]} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Foto Varian <span className="text-destructive">*</span></Label>
|
||||
<FileUpload
|
||||
value={variant.photo}
|
||||
onChange={(photo) => updateVariant(variantIndex, 'photo', photo)}
|
||||
folder="product-variant"
|
||||
onUploadingChange={(uploading) => updateVariant(variantIndex, 'uploading', uploading)}
|
||||
/>
|
||||
<InputError message={errors[`variants.${variantIndex}.photo_key`]} />
|
||||
</div>
|
||||
{!useSamePrice && (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{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={variant.prices[priceIndex]?.price ?? 0}
|
||||
onValueChange={(val) => updateVariantPrice(variantIndex, priceIndex, val)}
|
||||
/>
|
||||
<InputError message={errors[`variants.${variantIndex}.prices.${priceIndex}.price`]} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" onClick={addVariant}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah Varian
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-6">
|
||||
<Button type="submit" disabled={processing || variants.some((v) => v.uploading)}>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: variants.some((v) => v.uploading)
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ProductCreate.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Master',
|
||||
href: productIndex.url(),
|
||||
},
|
||||
{
|
||||
title: 'Produk',
|
||||
href: productIndex.url(),
|
||||
},
|
||||
{
|
||||
title: 'Tambah',
|
||||
href: '#',
|
||||
},
|
||||
],
|
||||
};
|
||||
528
resources/js/pages/admin/master/product/edit.tsx
Normal file
528
resources/js/pages/admin/master/product/edit.tsx
Normal file
@ -0,0 +1,528 @@
|
||||
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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { index as productIndex, update } from '@/routes/admin/master/products';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { ArrowLeft, Copy, ClipboardPaste, Check, Plus, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
type Category = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ProductVariant = {
|
||||
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 }>;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
product: {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: 'active' | 'inactive' | 'draft';
|
||||
category_ids: number[];
|
||||
product_variants: ProductVariant[];
|
||||
};
|
||||
categories: Category[];
|
||||
};
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
function createEmptyPrices(): Array<{ type: string; price: number }> {
|
||||
return PRICE_TYPES.map((pt) => ({ type: pt.key, price: 0 }));
|
||||
}
|
||||
|
||||
function arePricesEqual(a: Array<{ type: string; price: number }>, b: Array<{ type: string; price: number }>): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((pa, i) => pa.type === b[i]?.type && pa.price === b[i]?.price);
|
||||
}
|
||||
|
||||
type VariantState = {
|
||||
id: number | null;
|
||||
name: string;
|
||||
stock: number;
|
||||
reject_stock: number;
|
||||
retail_stock: number;
|
||||
photo: string | null;
|
||||
photoUrl: string | null;
|
||||
uploading: boolean;
|
||||
prices: Array<{ type: string; price: number }>;
|
||||
};
|
||||
|
||||
export default function ProductEdit({ product, categories }: Props) {
|
||||
const initialVariants: VariantState[] = product.product_variants.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
stock: v.stock,
|
||||
reject_stock: v.reject_stock,
|
||||
retail_stock: v.retail_stock,
|
||||
photo: v.photo_key,
|
||||
photoUrl: v.photo_url,
|
||||
uploading: false,
|
||||
prices: v.prices.length > 0 ? v.prices : createEmptyPrices(),
|
||||
}));
|
||||
|
||||
const allSamePrice = initialVariants.length > 1
|
||||
? initialVariants.every((v) => arePricesEqual(v.prices, initialVariants[0].prices))
|
||||
: true;
|
||||
|
||||
const [categoryIds, setCategoryIds] = useState<number[]>(product.category_ids);
|
||||
const [useSamePrice, setUseSamePrice] = useState(allSamePrice);
|
||||
const [sharedPrices, setSharedPrices] = useState<Array<{ type: string; price: number }>>(
|
||||
initialVariants.length > 0 ? initialVariants[0].prices : createEmptyPrices()
|
||||
);
|
||||
const [variants, setVariants] = useState<VariantState[]>(
|
||||
initialVariants.length > 0 ? initialVariants : [
|
||||
{
|
||||
id: null,
|
||||
name: '',
|
||||
stock: 0,
|
||||
reject_stock: 0,
|
||||
retail_stock: 0,
|
||||
photo: null,
|
||||
photoUrl: null,
|
||||
uploading: false,
|
||||
prices: createEmptyPrices(),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
const variantsRef = useRef(variants);
|
||||
variantsRef.current = variants;
|
||||
|
||||
const addVariant = useCallback(() => {
|
||||
setVariants((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: null,
|
||||
name: '',
|
||||
stock: 0,
|
||||
reject_stock: 0,
|
||||
retail_stock: 0,
|
||||
photo: null,
|
||||
photoUrl: null,
|
||||
uploading: false,
|
||||
prices: createEmptyPrices(),
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const removeVariant = useCallback((index: number) => {
|
||||
setVariants((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const updateVariant = useCallback((index: number, field: keyof VariantState, value: unknown) => {
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
(updated[index] as Record<string, unknown>)[field] = value;
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateVariantPrice = useCallback((variantIndex: number, priceIndex: number, value: number) => {
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[variantIndex] = {
|
||||
...updated[variantIndex],
|
||||
prices: updated[variantIndex].prices.map((p, i) =>
|
||||
i === priceIndex ? { ...p, price: value } : p
|
||||
),
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSharedPrice = useCallback((priceIndex: number, value: number) => {
|
||||
setSharedPrices((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[priceIndex] = { ...updated[priceIndex], price: value };
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
|
||||
const copyPrices = useCallback((variantIndex: number) => {
|
||||
setVariants((prev) => {
|
||||
const prices = prev[variantIndex].prices;
|
||||
navigator.clipboard.writeText(JSON.stringify(prices));
|
||||
setCopiedIndex(variantIndex);
|
||||
setTimeout(() => setCopiedIndex(null), 1500);
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pastePrices = useCallback((variantIndex: number) => {
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
try {
|
||||
const prices = JSON.parse(text) as Array<{ type: string; price: number }>;
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[variantIndex] = { ...updated[variantIndex], prices };
|
||||
return updated;
|
||||
});
|
||||
} catch {
|
||||
// invalid clipboard data
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyToAll = useCallback((variantIndex: number) => {
|
||||
setVariants((prev) => {
|
||||
const sourcePrices = prev[variantIndex].prices;
|
||||
return prev.map((v, i) =>
|
||||
i === variantIndex ? v : { ...v, prices: [...sourcePrices] }
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
function getPayload() {
|
||||
return {
|
||||
category_ids: categoryIds,
|
||||
use_same_price: useSamePrice,
|
||||
shared_prices: useSamePrice
|
||||
? sharedPrices.map((p) => ({ type: p.type, price: Number(p.price) }))
|
||||
: [],
|
||||
variants: variantsRef.current.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
stock: Number(v.stock),
|
||||
reject_stock: Number(v.reject_stock),
|
||||
retail_stock: Number(v.retail_stock),
|
||||
photo_key: v.photo,
|
||||
prices: useSamePrice
|
||||
? []
|
||||
: v.prices.map((p) => ({ type: p.type, price: Number(p.price) })),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Edit Produk" />
|
||||
|
||||
<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 Produk</h2>
|
||||
<Button asChild variant="outline">
|
||||
<a href={productIndex.url()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Kembali
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
action={update(product.id)}
|
||||
method="put"
|
||||
transform={(data) => ({
|
||||
...data,
|
||||
...getPayload(),
|
||||
})}
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<>
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informasi Produk</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama Produk <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
defaultValue={product.name}
|
||||
placeholder="Masukkan nama produk"
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Status <span className="text-destructive">*</span></Label>
|
||||
<RadioGroup name="status" defaultValue={product.status} className="flex gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="active" id="status-active" />
|
||||
<Label htmlFor="status-active" className="font-normal">Aktif</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="inactive" id="status-inactive" />
|
||||
<Label htmlFor="status-inactive" className="font-normal">Non Aktif</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="draft" id="status-draft" />
|
||||
<Label htmlFor="status-draft" className="font-normal">Draft</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<InputError message={errors.status} />
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label>Kategori <span className="text-destructive">*</span></Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((category) => (
|
||||
<label
|
||||
key={category.id}
|
||||
className="flex items-center space-x-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="category_ids[]"
|
||||
value={category.id}
|
||||
checked={categoryIds.includes(category.id)}
|
||||
onChange={(e) => {
|
||||
setCategoryIds((prev) =>
|
||||
e.target.checked
|
||||
? [...prev, category.id]
|
||||
: prev.filter((id) => id !== category.id)
|
||||
);
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>{category.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<InputError message={errors.category_ids} />
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="description">Deskripsi</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
defaultValue={product.description ?? ''}
|
||||
placeholder="Masukkan deskripsi produk"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Harga</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<RadioGroup
|
||||
name="use_same_price"
|
||||
value={useSamePrice ? '1' : '0'}
|
||||
onValueChange={(val) => setUseSamePrice(val === '1')}
|
||||
className="flex gap-6"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="1" id="price-same" />
|
||||
<Label htmlFor="price-same" className="font-normal">Semua varian sama</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="0" id="price-different" />
|
||||
<Label htmlFor="price-different" className="font-normal">Harga per varian</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{useSamePrice && (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{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={sharedPrices[priceIndex]?.price ?? 0}
|
||||
onValueChange={(val) => updateSharedPrice(priceIndex, val)}
|
||||
/>
|
||||
<InputError message={errors[`shared_prices.${priceIndex}.price`]} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Varian Produk</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{variants.map((variant, variantIndex) => (
|
||||
<div key={variantIndex} className="rounded-lg border p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">Varian {variantIndex + 1}</h4>
|
||||
<div className="flex items-center gap-1">
|
||||
{!useSamePrice && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyPrices(variantIndex)}
|
||||
>
|
||||
{copiedIndex === variantIndex ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
Salin
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pastePrices(variantIndex)}
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
Tempel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => applyToAll(variantIndex)}
|
||||
>
|
||||
Terapkan ke Semua
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{variantIndex > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => removeVariant(variantIndex)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div 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={variant.name}
|
||||
onChange={(e) => updateVariant(variantIndex, 'name', e.target.value)}
|
||||
placeholder="Contoh: Ukuran L, Warna Merah"
|
||||
/>
|
||||
<InputError message={errors[`variants.${variantIndex}.name`]} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Stok Bagus <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={variant.stock}
|
||||
onChange={(e) => updateVariant(variantIndex, 'stock', Number(e.target.value))}
|
||||
/>
|
||||
<InputError message={errors[`variants.${variantIndex}.stock`]} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Stok Reject <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={variant.reject_stock}
|
||||
onChange={(e) => updateVariant(variantIndex, 'reject_stock', Number(e.target.value))}
|
||||
/>
|
||||
<InputError message={errors[`variants.${variantIndex}.reject_stock`]} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Stok Ecer <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={variant.retail_stock}
|
||||
onChange={(e) => updateVariant(variantIndex, 'retail_stock', Number(e.target.value))}
|
||||
/>
|
||||
<InputError message={errors[`variants.${variantIndex}.retail_stock`]} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Foto Varian <span className="text-destructive">*</span></Label>
|
||||
<FileUpload
|
||||
value={variant.photo}
|
||||
onChange={(photo) => updateVariant(variantIndex, 'photo', photo)}
|
||||
folder="product-variant"
|
||||
existingUrl={variant.photoUrl}
|
||||
onUploadingChange={(uploading) => updateVariant(variantIndex, 'uploading', uploading)}
|
||||
/>
|
||||
<InputError message={errors[`variants.${variantIndex}.photo_key`]} />
|
||||
</div>
|
||||
{!useSamePrice && (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{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={variant.prices[priceIndex]?.price ?? 0}
|
||||
onValueChange={(val) => updateVariantPrice(variantIndex, priceIndex, val)}
|
||||
/>
|
||||
<InputError message={errors[`variants.${variantIndex}.prices.${priceIndex}.price`]} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" onClick={addVariant}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah Varian
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-6">
|
||||
<Button type="submit" disabled={processing || variants.some((v) => v.uploading)}>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: variants.some((v) => v.uploading)
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ProductEdit.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Master',
|
||||
href: productIndex.url(),
|
||||
},
|
||||
{
|
||||
title: 'Produk',
|
||||
href: productIndex.url(),
|
||||
},
|
||||
{
|
||||
title: 'Edit',
|
||||
href: '#',
|
||||
},
|
||||
],
|
||||
};
|
||||
320
resources/js/pages/admin/master/product/index.tsx
Normal file
320
resources/js/pages/admin/master/product/index.tsx
Normal file
@ -0,0 +1,320 @@
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from '@/components/ui/combobox';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { destroy, create as productCreate, index as productIndex, edit as productEdit, toggleStatus } from '@/routes/admin/master/products';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import type { Row } from '@tanstack/react-table';
|
||||
import { Filter, Plus, X } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { Product } from './columns';
|
||||
import { createProductColumns } from './columns';
|
||||
|
||||
type Props = {
|
||||
products: Product[];
|
||||
filters: {
|
||||
status?: string;
|
||||
name?: 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) {
|
||||
const [deleting, setDeleting] = useState<Product | null>(null);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
|
||||
const hasActiveFilters = filters.status || filters.name;
|
||||
|
||||
const productNames = useMemo(() => {
|
||||
const names = products.map((p) => p.name);
|
||||
return [...new Set(names)].sort();
|
||||
}, [products]);
|
||||
|
||||
function applyFilter(key: string, value: string) {
|
||||
const newFilters = { ...filters };
|
||||
|
||||
if (value === '' || value === 'all') {
|
||||
delete newFilters[key as keyof typeof newFilters];
|
||||
} else {
|
||||
newFilters[key as keyof typeof newFilters] = value;
|
||||
}
|
||||
|
||||
router.get(productIndex(), newFilters, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
router.get(productIndex(), {}, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
setFilterOpen(false);
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.delete(destroy.url(deleting.id), {
|
||||
onSuccess: () => setDeleting(null),
|
||||
});
|
||||
}
|
||||
|
||||
const columns = createProductColumns({
|
||||
handleEdit: (product) => {
|
||||
window.location.href = productEdit.url(product.id);
|
||||
},
|
||||
handleDeleteClick: (product) => setDeleting(product),
|
||||
toggleStatusUrl: (id) => toggleStatus.url(id),
|
||||
});
|
||||
|
||||
const filterToolbar = (
|
||||
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filter
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
|
||||
{Object.values(filters).filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="end">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Filter</span>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Hapus Semua
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Nama Produk
|
||||
</label>
|
||||
<Combobox
|
||||
value={filters.name ?? ''}
|
||||
onValueChange={(value) => applyFilter('name', value as string)}
|
||||
>
|
||||
<ComboboxInput placeholder="Pilih produk..." className="w-full" />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>Tidak ada produk ditemukan.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{productNames.map((name) => (
|
||||
<ComboboxItem key={name} value={name}>
|
||||
{name}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Status
|
||||
</label>
|
||||
<Select
|
||||
value={filters.status ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('status', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="active">Aktif</SelectItem>
|
||||
<SelectItem value="inactive">Non Aktif</SelectItem>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Produk" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Produk
|
||||
</h2>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<a href={productCreate.url()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={products}
|
||||
searchKey="variant_names"
|
||||
searchPlaceholder="Cari varian..."
|
||||
emptyText="Belum ada data produk."
|
||||
renderSubRow={(row, searchValue) => <VariantSubRow row={row} searchValue={searchValue} />}
|
||||
defaultExpanded
|
||||
toolbar={filterToolbar}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Produk"
|
||||
description={`Apakah Anda yakin ingin menghapus produk "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
|
||||
confirmLabel="Hapus"
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ProductIndex.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Master',
|
||||
href: productIndex.url(),
|
||||
},
|
||||
{
|
||||
title: 'Produk',
|
||||
href: productIndex.url(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -9,4 +9,5 @@
|
||||
|
||||
Route::get('/presigned-url/{key}', [PresignedUrlController::class, 'show'])
|
||||
->middleware(['web', 'auth', 'verified'])
|
||||
->name('presigned-url.show');
|
||||
->name('presigned-url.show')
|
||||
->where('key', '.*');
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
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\SupplierController;
|
||||
use App\Http\Controllers\Admin\RoleController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@ -29,9 +30,11 @@
|
||||
Route::inertia('dashboard', 'dashboard')->name('dashboard');
|
||||
|
||||
Route::prefix('admin/master')->name('admin.master.')->group(function () {
|
||||
Route::resource('categories', CategoryController::class)->except(['show', 'create', 'edit'])->middleware('permission:category.view|category.create|category.update|category.delete');
|
||||
Route::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit'])->middleware('permission:supplier.view|supplier.create|supplier.update|supplier.delete');
|
||||
Route::resource('customers', CustomerController::class)->except(['show', 'create', 'edit'])->middleware('permission:customer.view|customer.create|customer.update|customer.delete');
|
||||
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::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit']);
|
||||
Route::resource('customers', CustomerController::class)->except(['show', 'create', 'edit']);
|
||||
});
|
||||
|
||||
Route::prefix('admin/finance')->name('admin.finance.')->group(function () {
|
||||
|
||||
1994
tests/Feature/Admin/Master/ProductTest.php
Normal file
1994
tests/Feature/Admin/Master/ProductTest.php
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user