Add product management features including Product, ProductVariant, and ProductPrice models. Implement ProductController for CRUD operations, integrate permissions in Role and Permission enums, and enhance UI components for product listing and management. Introduce new migrations for products and related entities, and update sidebar for product navigation.
This commit is contained in:
parent
b695eeba9a
commit
448ac63260
@ -22,6 +22,12 @@ enum Permission: string
|
||||
case CATEGORIES_UPDATE = 'categories.update';
|
||||
case CATEGORIES_DELETE = 'categories.delete';
|
||||
|
||||
case PRODUCTS_VIEW = 'products.view';
|
||||
case PRODUCTS_CREATE = 'products.create';
|
||||
case PRODUCTS_UPDATE = 'products.update';
|
||||
case PRODUCTS_DELETE = 'products.delete';
|
||||
case PRODUCTS_TOGGLE_STATUS = 'products.toggle-status';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
@ -38,6 +44,12 @@ public function label(): string
|
||||
self::CATEGORIES_CREATE => 'Tambah Kategori',
|
||||
self::CATEGORIES_UPDATE => 'Ubah Kategori',
|
||||
self::CATEGORIES_DELETE => 'Hapus Kategori',
|
||||
|
||||
self::PRODUCTS_VIEW => 'Lihat Produk',
|
||||
self::PRODUCTS_CREATE => 'Tambah Produk',
|
||||
self::PRODUCTS_UPDATE => 'Ubah Produk',
|
||||
self::PRODUCTS_DELETE => 'Hapus Produk',
|
||||
self::PRODUCTS_TOGGLE_STATUS => 'Ubah Status Produk',
|
||||
};
|
||||
}
|
||||
|
||||
@ -49,6 +61,8 @@ public function group(): string
|
||||
self::EMPLOYEES_DELETE, self::EMPLOYEES_RESET_PASSWORD, self::EMPLOYEES_TOGGLE_STATUS => 'Pegawai',
|
||||
self::CATEGORIES_VIEW, self::CATEGORIES_CREATE, self::CATEGORIES_UPDATE,
|
||||
self::CATEGORIES_DELETE => 'Kategori',
|
||||
self::PRODUCTS_VIEW, self::PRODUCTS_CREATE, self::PRODUCTS_UPDATE,
|
||||
self::PRODUCTS_DELETE, self::PRODUCTS_TOGGLE_STATUS => 'Produk',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
39
app/Enums/PriceType.php
Normal file
39
app/Enums/PriceType.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Traits\ProvidesEnumOptions;
|
||||
|
||||
enum PriceType: string
|
||||
{
|
||||
use ProvidesEnumOptions;
|
||||
|
||||
case DISTRIBUTOR = 'distributor';
|
||||
case AGENT = 'agent';
|
||||
case SUB_AGENT = 'sub_agent';
|
||||
case GROSIR = 'grosir';
|
||||
case ECER = 'ecer';
|
||||
case TIKTOK = 'tiktok';
|
||||
case SHOPEE = 'shopee';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DISTRIBUTOR => 'Distributor',
|
||||
self::AGENT => 'Agen',
|
||||
self::SUB_AGENT => 'Sub Agen',
|
||||
self::GROSIR => 'Grosir',
|
||||
self::ECER => 'Eceran',
|
||||
self::TIKTOK => 'TikTok',
|
||||
self::SHOPEE => 'Shopee',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,11 @@ public function permissions(): array
|
||||
Permission::CATEGORIES_CREATE,
|
||||
Permission::CATEGORIES_UPDATE,
|
||||
Permission::CATEGORIES_DELETE,
|
||||
Permission::PRODUCTS_VIEW,
|
||||
Permission::PRODUCTS_CREATE,
|
||||
Permission::PRODUCTS_UPDATE,
|
||||
Permission::PRODUCTS_DELETE,
|
||||
Permission::PRODUCTS_TOGGLE_STATUS,
|
||||
],
|
||||
self::ADMIN_TOKO => [
|
||||
Permission::DASHBOARD_VIEW,
|
||||
@ -60,6 +65,11 @@ public function permissions(): array
|
||||
Permission::CATEGORIES_CREATE,
|
||||
Permission::CATEGORIES_UPDATE,
|
||||
Permission::CATEGORIES_DELETE,
|
||||
Permission::PRODUCTS_VIEW,
|
||||
Permission::PRODUCTS_CREATE,
|
||||
Permission::PRODUCTS_UPDATE,
|
||||
Permission::PRODUCTS_DELETE,
|
||||
Permission::PRODUCTS_TOGGLE_STATUS,
|
||||
],
|
||||
self::ADMIN_BAHAN_BAKU => [
|
||||
Permission::DASHBOARD_VIEW,
|
||||
@ -68,11 +78,17 @@ public function permissions(): array
|
||||
Permission::CATEGORIES_CREATE,
|
||||
Permission::CATEGORIES_UPDATE,
|
||||
Permission::CATEGORIES_DELETE,
|
||||
Permission::PRODUCTS_VIEW,
|
||||
Permission::PRODUCTS_CREATE,
|
||||
Permission::PRODUCTS_UPDATE,
|
||||
Permission::PRODUCTS_DELETE,
|
||||
Permission::PRODUCTS_TOGGLE_STATUS,
|
||||
],
|
||||
self::MARKETING => [
|
||||
Permission::DASHBOARD_VIEW,
|
||||
Permission::EMPLOYEES_VIEW,
|
||||
Permission::CATEGORIES_VIEW,
|
||||
Permission::PRODUCTS_VIEW,
|
||||
],
|
||||
self::NON_OPERATOR => [
|
||||
Permission::DASHBOARD_VIEW,
|
||||
|
||||
338
app/Http/Controllers/Admin/ProductController.php
Normal file
338
app/Http/Controllers/Admin/ProductController.php
Normal file
@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\ProductRequest;
|
||||
use App\Models\Category;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductPrice;
|
||||
use App\Models\ProductVariant;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
use ParsesDataTableQuery;
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$tableQuery = $this->parseDataTableQuery($request);
|
||||
$search = $tableQuery['search'];
|
||||
$sort = $tableQuery['sort'];
|
||||
$direction = $tableQuery['direction'];
|
||||
$isActive = $request->string('is_active')->toString();
|
||||
|
||||
$query = Product::query()
|
||||
->with([
|
||||
'categories',
|
||||
'variants' => fn ($query) => $query
|
||||
->with(['prices' => fn ($query) => $query->orderBy('type')])
|
||||
->orderBy('created_at'),
|
||||
])
|
||||
->when($search !== '', function (Builder $query) use ($search): void {
|
||||
$query->where(function (Builder $query) use ($search): void {
|
||||
$query->where('name', 'like', "%{$search}%")
|
||||
->orWhere('slug', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%")
|
||||
->orWhereHas('categories', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"))
|
||||
->orWhereHas('variants', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
|
||||
});
|
||||
})
|
||||
->when(
|
||||
$isActive !== '',
|
||||
fn (Builder $query) => $query->where('is_active', $isActive === '1')
|
||||
);
|
||||
|
||||
$this->applySorting($query, $sort, $direction);
|
||||
|
||||
$products = $query
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
$rows = $products->getCollection()
|
||||
->flatMap(fn (Product $product, int $index) => $this->flattenProductForTable($product, $index, $products->firstItem() ?? 1))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return Inertia::render('admin/products/Index', [
|
||||
'rows' => $rows,
|
||||
'pagination' => [
|
||||
'current_page' => $products->currentPage(),
|
||||
'last_page' => $products->lastPage(),
|
||||
'per_page' => $products->perPage(),
|
||||
'total' => $products->total(),
|
||||
'links' => $products->linkCollection()->toArray(),
|
||||
],
|
||||
'filters' => $this->dataTableFilters($tableQuery, [
|
||||
'is_active' => $isActive,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('admin/products/Create', [
|
||||
'categories' => $this->categoryOptions(),
|
||||
'priceTypes' => PriceType::selectOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(ProductRequest $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
DB::transaction(function () use ($validated): void {
|
||||
$product = Product::create([
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$product->categories()->sync($validated['category_ids']);
|
||||
|
||||
foreach ($validated['variants'] as $variantData) {
|
||||
$this->createVariant($product, $variantData);
|
||||
}
|
||||
});
|
||||
|
||||
Inertia::flash('success', 'Produk berhasil ditambahkan.');
|
||||
|
||||
return redirect()->route('admin.master.products.index');
|
||||
}
|
||||
|
||||
public function edit(Product $product): Response
|
||||
{
|
||||
$product->load([
|
||||
'categories',
|
||||
'variants' => fn ($query) => $query
|
||||
->with(['prices' => fn ($query) => $query->orderBy('type')])
|
||||
->orderBy('created_at'),
|
||||
]);
|
||||
|
||||
return Inertia::render('admin/products/Edit', [
|
||||
'categories' => $this->categoryOptions(),
|
||||
'priceTypes' => PriceType::selectOptions(),
|
||||
'product' => $this->transformProductForForm($product),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(ProductRequest $request, Product $product): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
DB::transaction(function () use ($validated, $product): void {
|
||||
$product->name = $validated['name'];
|
||||
$product->description = $validated['description'] ?? null;
|
||||
$product->save();
|
||||
|
||||
$product->categories()->sync($validated['category_ids']);
|
||||
|
||||
$submittedVariantIds = collect($validated['variants'])
|
||||
->pluck('id')
|
||||
->filter()
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
$product->variants()
|
||||
->whereNotIn('id', $submittedVariantIds)
|
||||
->get()
|
||||
->each(fn (ProductVariant $variant) => $variant->delete());
|
||||
|
||||
foreach ($validated['variants'] as $variantData) {
|
||||
if (! empty($variantData['id'])) {
|
||||
$variant = $product->variants()->findOrFail($variantData['id']);
|
||||
$variant->name = $variantData['name'];
|
||||
$variant->stock = $variantData['stock'];
|
||||
$variant->save();
|
||||
$this->syncVariantPrices($variant, $variantData['prices']);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->createVariant($product, $variantData);
|
||||
}
|
||||
});
|
||||
|
||||
Inertia::flash('success', 'Produk berhasil diperbarui.');
|
||||
|
||||
return redirect()->route('admin.master.products.index');
|
||||
}
|
||||
|
||||
public function toggleStatus(Request $request, Product $product): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'is_active' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
$product->is_active = $validated['is_active'];
|
||||
$product->save();
|
||||
|
||||
Inertia::flash('success', 'Status produk berhasil diperbarui.');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function destroy(Product $product): RedirectResponse
|
||||
{
|
||||
DB::transaction(function () use ($product): void {
|
||||
$product->variants()->delete();
|
||||
$product->categories()->detach();
|
||||
$product->delete();
|
||||
});
|
||||
|
||||
Inertia::flash('success', 'Produk berhasil dihapus.');
|
||||
|
||||
return redirect()->route('admin.master.products.index');
|
||||
}
|
||||
|
||||
private function applySorting(Builder $query, string $sort, string $direction): void
|
||||
{
|
||||
if (in_array($sort, ['name', 'slug', 'is_active'], true)) {
|
||||
$query->orderBy($sort, $direction);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->latest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{value: int, label: string}>
|
||||
*/
|
||||
private function categoryOptions(): array
|
||||
{
|
||||
return Category::query()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name'])
|
||||
->map(fn (Category $category) => [
|
||||
'value' => $category->id,
|
||||
'label' => $category->name,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $variantData
|
||||
*/
|
||||
private function createVariant(Product $product, array $variantData): ProductVariant
|
||||
{
|
||||
$variant = $product->variants()->create([
|
||||
'name' => $variantData['name'],
|
||||
'stock' => $variantData['stock'],
|
||||
]);
|
||||
|
||||
$this->syncVariantPrices($variant, $variantData['prices']);
|
||||
|
||||
return $variant;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{type: string, price: float|int|string}> $prices
|
||||
*/
|
||||
private function syncVariantPrices(ProductVariant $variant, array $prices): void
|
||||
{
|
||||
foreach ($prices as $priceData) {
|
||||
ProductPrice::updateOrCreate(
|
||||
[
|
||||
'variant_id' => $variant->id,
|
||||
'type' => $priceData['type'],
|
||||
],
|
||||
[
|
||||
'price' => $priceData['price'],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function flattenProductForTable(Product $product, int $productIndex, int $firstItem): array
|
||||
{
|
||||
$variants = $product->variants->isNotEmpty()
|
||||
? $product->variants
|
||||
: collect([null]);
|
||||
|
||||
$variantCount = $variants->count();
|
||||
|
||||
return $variants
|
||||
->values()
|
||||
->map(function ($variant, int $variantIndex) use ($product, $productIndex, $firstItem, $variantCount) {
|
||||
return [
|
||||
'row_id' => $variant
|
||||
? "{$product->id}-{$variant->id}"
|
||||
: "{$product->id}-empty",
|
||||
'product_id' => $product->id,
|
||||
'product_name' => $product->name,
|
||||
'categories' => $product->categories
|
||||
->map(fn (Category $category) => [
|
||||
'id' => $category->id,
|
||||
'name' => $category->name,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
'is_active' => $product->is_active,
|
||||
'is_first_variant' => $variantIndex === 0,
|
||||
'variant_count' => $variantCount,
|
||||
'product_row_number' => $firstItem + $productIndex,
|
||||
'variant_id' => $variant?->id,
|
||||
'variant_name' => $variant?->name,
|
||||
'stock' => $variant?->stock,
|
||||
'prices' => $variant
|
||||
? $variant->prices
|
||||
->map(fn (ProductPrice $price) => [
|
||||
'type' => $price->type->value,
|
||||
'type_label' => $price->type->label(),
|
||||
'price' => $price->price,
|
||||
'price_formatted' => $this->formatPrice($price->price),
|
||||
])
|
||||
->values()
|
||||
->all()
|
||||
: [],
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function transformProductForForm(Product $product): array
|
||||
{
|
||||
return [
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'description' => $product->description,
|
||||
'category_ids' => $product->categories->pluck('id')->all(),
|
||||
'variants' => $product->variants
|
||||
->map(fn (ProductVariant $variant) => [
|
||||
'id' => $variant->id,
|
||||
'name' => $variant->name,
|
||||
'stock' => $variant->stock,
|
||||
'prices' => collect(PriceType::cases())
|
||||
->mapWithKeys(function (PriceType $type) use ($variant) {
|
||||
$price = $variant->prices->firstWhere('type', $type);
|
||||
|
||||
return [
|
||||
$type->value => $price ? (string) (int) $price->price : '',
|
||||
];
|
||||
})
|
||||
->all(),
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatPrice(float|string $price): string
|
||||
{
|
||||
return 'Rp '.number_format((float) $price, 0, ',', '.');
|
||||
}
|
||||
}
|
||||
46
app/Http/Requests/Admin/ProductRequest.php
Normal file
46
app/Http/Requests/Admin/ProductRequest.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\PriceType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProductRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$permission = $this->isMethod('POST')
|
||||
? Permission::PRODUCTS_CREATE
|
||||
: Permission::PRODUCTS_UPDATE;
|
||||
|
||||
return $this->user()?->can($permission->value) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:200'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'category_ids' => ['required', 'array', 'min:1'],
|
||||
'category_ids.*' => ['integer', Rule::exists('categories', 'id')->whereNull('deleted_at')],
|
||||
'variants' => ['required', 'array', 'min:1'],
|
||||
'variants.*.id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('product_variants', 'id')
|
||||
->where('product_id', $this->route('product')?->id)
|
||||
->whereNull('deleted_at'),
|
||||
],
|
||||
'variants.*.name' => ['required', 'string', 'max:200'],
|
||||
'variants.*.stock' => ['required', 'integer', 'min:0'],
|
||||
'variants.*.prices' => ['required', 'array', 'min:1'],
|
||||
'variants.*.prices.*.type' => ['required', Rule::enum(PriceType::class)],
|
||||
'variants.*.prices.*.price' => ['required', 'numeric', 'gt:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Sluggable\Attributes\Sluggable;
|
||||
|
||||
@ -12,4 +13,9 @@
|
||||
class Category extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
public function products(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Product::class, 'product_categories');
|
||||
}
|
||||
}
|
||||
|
||||
34
app/Models/Product.php
Normal file
34
app/Models/Product.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
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 SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function categories(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Category::class, 'product_categories');
|
||||
}
|
||||
|
||||
public function variants(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductVariant::class);
|
||||
}
|
||||
}
|
||||
25
app/Models/ProductPrice.php
Normal file
25
app/Models/ProductPrice.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class ProductPrice extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => PriceType::class,
|
||||
'price' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function variant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductVariant::class, 'variant_id');
|
||||
}
|
||||
}
|
||||
32
app/Models/ProductVariant.php
Normal file
32
app/Models/ProductVariant.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class ProductVariant extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'stock' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function prices(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductPrice::class, 'variant_id');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('products', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name', 200);
|
||||
$table->string('slug', 200)->unique();
|
||||
$table->text('description')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('products');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('product_categories', function (Blueprint $table) {
|
||||
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
$table->primary(['product_id', 'category_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('product_categories');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('product_variants', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name', 200);
|
||||
$table->unsignedInteger('stock')->default(0);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index('product_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('product_variants');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('product_prices', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete();
|
||||
$table->string('type', 20);
|
||||
$table->decimal('price', 18, 2);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['variant_id', 'type']);
|
||||
$table->index('variant_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('product_prices');
|
||||
}
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { FolderTree, LayoutDashboard, Users } from '@lucide/vue';
|
||||
import { FolderTree, LayoutDashboard, Package, Users } from '@lucide/vue';
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
Sidebar,
|
||||
@ -22,6 +22,8 @@ const { can } = useCan();
|
||||
const isDashboardActive = computed(() => page.url.startsWith('/admin/dashboard'));
|
||||
const isEmployeesActive = computed(() => page.url.startsWith('/admin/hr/employees'));
|
||||
const isCategoriesActive = computed(() => page.url.startsWith('/admin/master/categories'));
|
||||
const isProductsActive = computed(() => page.url.startsWith('/admin/master/products'));
|
||||
const showMasterMenu = computed(() => can('categories.view') || can('products.view'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -55,11 +57,11 @@ const isCategoriesActive = computed(() => page.url.startsWith('/admin/master/cat
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup v-if="can('categories.view')">
|
||||
<SidebarGroup v-if="showMasterMenu">
|
||||
<SidebarGroupLabel>Master</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuItem v-if="can('categories.view')">
|
||||
<SidebarMenuButton as-child tooltip="Kategori" :is-active="isCategoriesActive">
|
||||
<Link href="/admin/master/categories">
|
||||
<FolderTree />
|
||||
@ -67,6 +69,14 @@ const isCategoriesActive = computed(() => page.url.startsWith('/admin/master/cat
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem v-if="can('products.view')">
|
||||
<SidebarMenuButton as-child tooltip="Produk" :is-active="isProductsActive">
|
||||
<Link href="/admin/master/products">
|
||||
<Package />
|
||||
<span>Produk</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
@ -35,9 +35,15 @@ const props = withDefaults(
|
||||
filterValues?: Record<string, string>;
|
||||
searchPlaceholder?: string;
|
||||
paginationLinks?: DataTablePaginationLink[];
|
||||
showRowNumber?: boolean;
|
||||
paginationDisplayedCount?: number;
|
||||
paginationItemLabel?: string;
|
||||
getRowClassName?: (row: TData, index: number) => string | undefined;
|
||||
}>(),
|
||||
{
|
||||
searchPlaceholder: 'Ketikkan sesuatu...',
|
||||
showRowNumber: true,
|
||||
paginationItemLabel: 'data',
|
||||
}
|
||||
);
|
||||
|
||||
@ -66,7 +72,9 @@ const rowNumberColumn: ColumnDef<TData> = {
|
||||
},
|
||||
};
|
||||
|
||||
const resolvedColumns = computed(() => [rowNumberColumn, ...props.columns]);
|
||||
const resolvedColumns = computed(() => (
|
||||
props.showRowNumber ? [rowNumberColumn, ...props.columns] : props.columns
|
||||
));
|
||||
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
@ -102,7 +110,7 @@ provide('data-table-sort', {
|
||||
onSort: handleSort,
|
||||
});
|
||||
|
||||
const showingCount = computed(() => props.data.length);
|
||||
const showingCount = computed(() => props.paginationDisplayedCount ?? props.data.length);
|
||||
|
||||
const paginationSummary = computed(() => {
|
||||
if (!props.pagination) {
|
||||
@ -110,13 +118,36 @@ const paginationSummary = computed(() => {
|
||||
}
|
||||
|
||||
const { total } = props.pagination;
|
||||
const label = props.paginationItemLabel;
|
||||
|
||||
if (total === 0) {
|
||||
return 'Menampilkan 0 data';
|
||||
return `Menampilkan 0 ${label}`;
|
||||
}
|
||||
|
||||
return `Menampilkan ${showingCount.value} data dari ${total}`;
|
||||
return `Menampilkan ${showingCount.value} ${label} dari ${total}`;
|
||||
});
|
||||
|
||||
function resolveRowSpan(row: TData, columnId: string): number | undefined {
|
||||
const column = resolvedColumns.value.find((item) => {
|
||||
if ('id' in item && item.id === columnId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ('accessorKey' in item && item.accessorKey === columnId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const getRowSpan = column?.meta?.getRowSpan;
|
||||
|
||||
if (!getRowSpan) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return getRowSpan(row);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -141,15 +172,30 @@ const paginationSummary = computed(() => {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows.length">
|
||||
<TableRow v-for="row in table.getRowModel().rows" :key="row.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined">
|
||||
<TableCell
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
:class="getRowClassName?.(row.original, row.index)"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
>
|
||||
<template
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
:class="cell.column.id === '_row_number' ? ROW_NUMBER_COLUMN_CLASS : undefined"
|
||||
>
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
v-if="(resolveRowSpan(row.original, cell.column.id) ?? 1) !== 0"
|
||||
:rowspan="(resolveRowSpan(row.original, cell.column.id) ?? 1) > 1
|
||||
? resolveRowSpan(row.original, cell.column.id)
|
||||
: undefined"
|
||||
:class="[
|
||||
cell.column.id === '_row_number' ? ROW_NUMBER_COLUMN_CLASS : undefined,
|
||||
(resolveRowSpan(row.original, cell.column.id) ?? 1) > 1 ? 'align-middle' : undefined,
|
||||
cell.column.columnDef.meta?.cellClassName,
|
||||
]"
|
||||
>
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</template>
|
||||
</TableRow>
|
||||
</template>
|
||||
<TableEmpty v-else :colspan="resolvedColumns.length">
|
||||
|
||||
394
resources/js/components/products/ProductForm.vue
Normal file
394
resources/js/components/products/ProductForm.vue
Normal file
@ -0,0 +1,394 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Copy, Plus, Save, Trash2 } from '@lucide/vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSet,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RupiahInput } from '@/components/ui/rupiah-input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { parseRupiah } from '@/lib/rupiah';
|
||||
import {
|
||||
PRICE_TYPES,
|
||||
PRICE_TYPE_LABELS
|
||||
} from '@/types/product';
|
||||
import type { CategoryOption, ProductFormData, ProductVariantFormItem } from '@/types/product';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
categories: CategoryOption[];
|
||||
initialData?: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
category_ids?: number[];
|
||||
variants?: Array<{
|
||||
id?: number;
|
||||
name?: string;
|
||||
stock?: number | string;
|
||||
prices?: Record<string, string>;
|
||||
}>;
|
||||
};
|
||||
submitUrl: string;
|
||||
method?: 'post' | 'put';
|
||||
submitLabel?: string;
|
||||
}>(),
|
||||
{
|
||||
method: 'post',
|
||||
submitLabel: 'Simpan',
|
||||
},
|
||||
);
|
||||
|
||||
function createClientId(): string {
|
||||
return `variant-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
function buildEmptyPrices(): Record<string, string> {
|
||||
return Object.fromEntries(PRICE_TYPES.map((type) => [type, '']));
|
||||
}
|
||||
|
||||
function createEmptyVariant(): ProductVariantFormItem {
|
||||
return {
|
||||
client_id: createClientId(),
|
||||
name: '',
|
||||
stock: '0',
|
||||
prices: buildEmptyPrices(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildInitialVariants(): ProductVariantFormItem[] {
|
||||
if (!props.initialData?.variants?.length) {
|
||||
return [createEmptyVariant()];
|
||||
}
|
||||
|
||||
return props.initialData.variants.map((variant) => ({
|
||||
client_id: createClientId(),
|
||||
id: variant.id,
|
||||
name: variant.name ?? '',
|
||||
stock: variant.stock != null ? String(variant.stock) : '0',
|
||||
prices: {
|
||||
...buildEmptyPrices(),
|
||||
...(variant.prices ?? {}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const variants = ref<ProductVariantFormItem[]>(buildInitialVariants());
|
||||
const useSamePrices = ref(variants.value.length <= 1 || allVariantsHaveSamePrices(variants.value));
|
||||
|
||||
const form = useForm({
|
||||
name: props.initialData?.name ?? '',
|
||||
description: props.initialData?.description ?? '',
|
||||
category_ids: props.initialData?.category_ids ?? [],
|
||||
});
|
||||
|
||||
function allVariantsHaveSamePrices(items: ProductVariantFormItem[]): boolean {
|
||||
if (items.length <= 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const first = items[0].prices;
|
||||
|
||||
return items.every((variant) =>
|
||||
PRICE_TYPES.every((type) => variant.prices[type].trim() === first[type].trim()),
|
||||
);
|
||||
}
|
||||
|
||||
function toggleCategory(categoryId: number, checked: boolean) {
|
||||
if (checked) {
|
||||
if (!form.category_ids.includes(categoryId)) {
|
||||
form.category_ids = [...form.category_ids, categoryId];
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
form.category_ids = form.category_ids.filter((id) => id !== categoryId);
|
||||
}
|
||||
|
||||
function isCategoryChecked(categoryId: number): boolean {
|
||||
return form.category_ids.includes(categoryId);
|
||||
}
|
||||
|
||||
function addVariant() {
|
||||
const newVariant = createEmptyVariant();
|
||||
|
||||
if (useSamePrices.value && variants.value[0]) {
|
||||
newVariant.prices = { ...variants.value[0].prices };
|
||||
}
|
||||
|
||||
variants.value = [...variants.value, newVariant];
|
||||
}
|
||||
|
||||
function removeVariant(clientId: string) {
|
||||
if (variants.value.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
variants.value = variants.value.filter((variant) => variant.client_id !== clientId);
|
||||
}
|
||||
|
||||
function setVariantField(clientId: string, key: 'name' | 'stock', value: string) {
|
||||
variants.value = variants.value.map((variant) =>
|
||||
variant.client_id === clientId ? { ...variant, [key]: value } : variant,
|
||||
);
|
||||
}
|
||||
|
||||
function setVariantPrice(clientId: string, type: string, value: string) {
|
||||
variants.value = variants.value.map((variant) =>
|
||||
variant.client_id === clientId
|
||||
? { ...variant, prices: { ...variant.prices, [type]: value } }
|
||||
: variant,
|
||||
);
|
||||
}
|
||||
|
||||
function setSharedPrice(type: string, value: string) {
|
||||
variants.value = variants.value.map((variant) => ({
|
||||
...variant,
|
||||
prices: { ...variant.prices, [type]: value },
|
||||
}));
|
||||
}
|
||||
|
||||
function toggleUseSamePrices(checked: boolean) {
|
||||
useSamePrices.value = checked;
|
||||
|
||||
if (!checked || !variants.value[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourcePrices = { ...variants.value[0].prices };
|
||||
variants.value = variants.value.map((variant) => ({
|
||||
...variant,
|
||||
prices: { ...sourcePrices },
|
||||
}));
|
||||
}
|
||||
|
||||
function applyPricesToAllVariants(sourceClientId: string) {
|
||||
const source = variants.value.find((variant) => variant.client_id === sourceClientId);
|
||||
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
|
||||
variants.value = variants.value.map((variant) => ({
|
||||
...variant,
|
||||
prices: { ...source.prices },
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSubmitPayload(): ProductFormData {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
category_ids: form.category_ids,
|
||||
variants: variants.value.map((variant) => ({
|
||||
...(variant.id ? { id: variant.id } : {}),
|
||||
name: variant.name.trim(),
|
||||
stock: Number.parseInt(variant.stock, 10) || 0,
|
||||
prices: PRICE_TYPES.map((type) => ({
|
||||
type,
|
||||
price: Number.parseInt(parseRupiah(variant.prices[type]), 10) || 0,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function formError(key: string): string | undefined {
|
||||
return (form.errors as Record<string, string>)[key];
|
||||
}
|
||||
|
||||
function variantError(clientId: string, field: string): string | undefined {
|
||||
const index = variants.value.findIndex((variant) => variant.client_id === clientId);
|
||||
|
||||
if (index === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return formError(`variants.${index}.${field}`)
|
||||
?? formError(`variants.${index}.prices`);
|
||||
}
|
||||
|
||||
function variantPriceError(clientId: string, type: string): string | undefined {
|
||||
const index = variants.value.findIndex((variant) => variant.client_id === clientId);
|
||||
|
||||
if (index === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const priceIndex = PRICE_TYPES.indexOf(type as typeof PRICE_TYPES[number]);
|
||||
|
||||
return formError(`variants.${index}.prices.${priceIndex}.price`)
|
||||
?? formError(`variants.${index}.prices.${priceIndex}.type`);
|
||||
}
|
||||
|
||||
const categoryError = computed(() => form.errors.category_ids);
|
||||
|
||||
function submit() {
|
||||
const options = {
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
|
||||
},
|
||||
};
|
||||
|
||||
const payload = buildSubmitPayload();
|
||||
|
||||
if (props.method === 'put') {
|
||||
form.transform(() => payload).put(props.submitUrl, options);
|
||||
} else {
|
||||
form.transform(() => payload).post(props.submitUrl, options);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="submit">
|
||||
<div class="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informasi Produk</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<FieldSet class="grid gap-4">
|
||||
<Field>
|
||||
<FieldLabel for="name" required>Nama Produk</FieldLabel>
|
||||
<Input id="name" v-model="form.name" type="text" placeholder="Masukkan nama produk" />
|
||||
<FieldError :errors="form.errors.name ? [form.errors.name] : []" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="description">Deskripsi</FieldLabel>
|
||||
<Textarea id="description" v-model="form.description"
|
||||
placeholder="Deskripsi produk (opsional)" rows="4" />
|
||||
<FieldError :errors="form.errors.description ? [form.errors.description] : []" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Kategori</FieldLabel>
|
||||
<div v-if="categories.length > 0"
|
||||
class="grid gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
<label v-for="category in categories" :key="category.value"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2">
|
||||
<input type="checkbox" class="size-4 rounded border-input"
|
||||
:checked="isCategoryChecked(category.value)"
|
||||
@change="toggleCategory(category.value, ($event.target as HTMLInputElement).checked)">
|
||||
<span class="text-sm">{{ category.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-else class="text-muted-foreground text-sm">
|
||||
Belum ada kategori. Tambahkan kategori terlebih dahulu.
|
||||
</p>
|
||||
<FieldError :errors="categoryError ? [categoryError] : []" />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-if="variants.length > 1">
|
||||
<CardHeader>
|
||||
<CardTitle>Harga Bersama</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<label class="mb-4 flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" class="size-4 rounded border-input" :checked="useSamePrices"
|
||||
@change="toggleUseSamePrices(($event.target as HTMLInputElement).checked)">
|
||||
<span class="text-sm">Gunakan harga yang sama untuk semua varian</span>
|
||||
</label>
|
||||
|
||||
<FieldSet v-if="useSamePrices && variants[0]"
|
||||
class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<Field v-for="type in PRICE_TYPES" :key="type">
|
||||
<FieldLabel :for="`shared_price_${type}`" required>
|
||||
{{ PRICE_TYPE_LABELS[type] }}
|
||||
</FieldLabel>
|
||||
<RupiahInput :id="`shared_price_${type}`" :model-value="variants[0].prices[type]"
|
||||
@update:model-value="setSharedPrice(type, $event)" />
|
||||
<FieldError
|
||||
:errors="variantPriceError(variants[0].client_id, type) ? [variantPriceError(variants[0].client_id, type)!] : []" />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-for="(variant, index) in variants" :key="variant.client_id">
|
||||
<CardHeader class="flex flex-row items-start justify-between gap-4">
|
||||
<CardTitle>Varian {{ index + 1 }}</CardTitle>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button v-if="variants.length > 1 && !useSamePrices" type="button" variant="outline" size="sm"
|
||||
@click="applyPricesToAllVariants(variant.client_id)">
|
||||
<Copy class="size-4" />
|
||||
Terapkan Harga ke Semua
|
||||
</Button>
|
||||
<Button v-if="variants.length > 1" type="button" variant="outline" size="icon"
|
||||
class="text-destructive hover:text-destructive size-8"
|
||||
@click="removeVariant(variant.client_id)">
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<FieldSet class="grid gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel :for="`variant_name_${variant.client_id}`" required>
|
||||
Nama Varian
|
||||
</FieldLabel>
|
||||
<Input :id="`variant_name_${variant.client_id}`" :model-value="variant.name" type="text"
|
||||
placeholder="Contoh: Merah / L"
|
||||
@update:model-value="setVariantField(variant.client_id, 'name', String($event))" />
|
||||
<FieldError
|
||||
:errors="variantError(variant.client_id, 'name') ? [variantError(variant.client_id, 'name')!] : []" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel :for="`variant_stock_${variant.client_id}`" required>
|
||||
Stok
|
||||
</FieldLabel>
|
||||
<Input :id="`variant_stock_${variant.client_id}`" :model-value="variant.stock"
|
||||
type="number" min="0"
|
||||
@update:model-value="setVariantField(variant.client_id, 'stock', String($event))" />
|
||||
<FieldError
|
||||
:errors="variantError(variant.client_id, 'stock') ? [variantError(variant.client_id, 'stock')!] : []" />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet v-if="!useSamePrices || variants.length === 1"
|
||||
class="mt-4 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<Field v-for="type in PRICE_TYPES" :key="`${variant.client_id}_${type}`">
|
||||
<FieldLabel :for="`price_${variant.client_id}_${type}`" required>
|
||||
{{ PRICE_TYPE_LABELS[type] }}
|
||||
</FieldLabel>
|
||||
<RupiahInput :id="`price_${variant.client_id}_${type}`"
|
||||
:model-value="variant.prices[type]"
|
||||
@update:model-value="setVariantPrice(variant.client_id, type, $event)" />
|
||||
<FieldError
|
||||
:errors="variantPriceError(variant.client_id, type) ? [variantPriceError(variant.client_id, type)!] : []" />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Button type="button" variant="outline" @click="addVariant">
|
||||
<Plus class="size-4" />
|
||||
Tambah Varian
|
||||
</Button>
|
||||
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
221
resources/js/components/products/ProductGroupedTable.vue
Normal file
221
resources/js/components/products/ProductGroupedTable.vue
Normal file
@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import DataTableActions from '@/components/products/data-table-actions.vue';
|
||||
import ProductStatusToggle from '@/components/products/product-status-toggle.vue';
|
||||
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { groupProductRows } from '@/lib/products';
|
||||
import type {
|
||||
DataTableFilterDef,
|
||||
DataTablePagination,
|
||||
DataTablePaginationLink,
|
||||
} from '@/types/data-table';
|
||||
import {
|
||||
PRICE_TYPES,
|
||||
PRICE_TYPE_LABELS,
|
||||
type ProductTableRow,
|
||||
} from '@/types/product';
|
||||
|
||||
const props = defineProps<{
|
||||
rows: ProductTableRow[];
|
||||
pagination?: DataTablePagination;
|
||||
paginationLinks?: DataTablePaginationLink[];
|
||||
paginationDisplayedCount?: number;
|
||||
filterDefs?: DataTableFilterDef[];
|
||||
filterValues?: Record<string, string>;
|
||||
}>();
|
||||
|
||||
const search = defineModel<string>('search', { default: '' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
'filter-change': [key: string, value: string];
|
||||
'filters-reset': [];
|
||||
}>();
|
||||
|
||||
const productGroups = computed(() => groupProductRows(props.rows));
|
||||
|
||||
const showingCount = computed(() => props.paginationDisplayedCount ?? productGroups.value.length);
|
||||
|
||||
const paginationSummary = computed(() => {
|
||||
if (!props.pagination) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { total } = props.pagination;
|
||||
|
||||
if (total === 0) {
|
||||
return 'Menampilkan 0 produk';
|
||||
}
|
||||
|
||||
return `Menampilkan ${showingCount.value} produk dari ${total}`;
|
||||
});
|
||||
|
||||
function formatStock(value: number | null): string {
|
||||
if (value === null) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return value.toLocaleString('id-ID');
|
||||
}
|
||||
|
||||
function groupHeaderRow(group: ReturnType<typeof groupProductRows>[number]): ProductTableRow {
|
||||
return group.variants[0];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<DataTableToolbar
|
||||
v-model:search="search"
|
||||
:filter-defs="filterDefs"
|
||||
:filter-values="filterValues"
|
||||
@filter-change="(key, value) => emit('filter-change', key, value)"
|
||||
@filters-reset="emit('filters-reset')"
|
||||
/>
|
||||
|
||||
<div v-if="productGroups.length" class="space-y-4">
|
||||
<div
|
||||
v-for="group in productGroups"
|
||||
:key="group.product_id"
|
||||
class="overflow-hidden rounded-md border"
|
||||
>
|
||||
<div class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<span class="text-muted-foreground w-8 shrink-0 pt-0.5 text-center text-sm tabular-nums">
|
||||
{{ group.product_row_number }}
|
||||
</span>
|
||||
<div class="min-w-0 space-y-2">
|
||||
<h3 class="font-medium leading-tight">
|
||||
{{ group.product_name }}
|
||||
</h3>
|
||||
<div v-if="group.categories.length" class="flex flex-wrap gap-1">
|
||||
<Badge
|
||||
v-for="category in group.categories"
|
||||
:key="category.id"
|
||||
variant="outline"
|
||||
>
|
||||
{{ category.name }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p v-else class="text-muted-foreground text-sm">
|
||||
Tanpa kategori
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
|
||||
<ProductStatusToggle :row="groupHeaderRow(group)" />
|
||||
<DataTableActions :row="groupHeaderRow(group)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead>Stok</TableHead>
|
||||
<TableHead>Harga</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="variant in group.variants"
|
||||
:key="variant.row_id"
|
||||
>
|
||||
<TableCell class="font-medium">
|
||||
{{ variant.variant_name ?? '-' }}
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ formatStock(variant.stock) }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
v-if="variant.prices.length"
|
||||
class="space-y-0.5 text-xs"
|
||||
>
|
||||
<div
|
||||
v-for="type in PRICE_TYPES"
|
||||
:key="type"
|
||||
>
|
||||
<div
|
||||
v-if="variant.prices.find((item) => item.type === type)"
|
||||
class="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span class="text-muted-foreground">
|
||||
{{ PRICE_TYPE_LABELS[type] }}
|
||||
</span>
|
||||
<span class="font-medium tabular-nums">
|
||||
{{
|
||||
variant.prices.find((item) => item.type === type)?.price_formatted
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-md border px-6 py-10"
|
||||
>
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Data tidak ditemukan</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Silakan lakukan pencarian atau filter untuk menemukan data yang Anda cari.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="pagination"
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ paginationSummary }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="paginationLinks?.length && pagination.lastPage > 1"
|
||||
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end"
|
||||
>
|
||||
<Button
|
||||
v-for="link in paginationLinks"
|
||||
:key="`${link.label}-${link.url}`"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!link.url || link.active"
|
||||
as-child
|
||||
>
|
||||
<Link v-if="link.url" :href="link.url" preserve-scroll>
|
||||
<span v-html="link.label" />
|
||||
</Link>
|
||||
<span v-else v-html="link.label" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
128
resources/js/components/products/columns.ts
Normal file
128
resources/js/components/products/columns.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { h } from 'vue';
|
||||
import { DataTableColumnHeader } from '@/components/data-table';
|
||||
import DataTableActions from '@/components/products/data-table-actions.vue';
|
||||
import ProductStatusToggle from '@/components/products/product-status-toggle.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PRICE_TYPES, PRICE_TYPE_LABELS, type ProductTableRow } from '@/types/product';
|
||||
|
||||
const GROUPED_CELL_CLASS = 'align-middle';
|
||||
|
||||
function productRowSpan(row: ProductTableRow): number {
|
||||
return row.is_first_variant ? row.variant_count : 0;
|
||||
}
|
||||
|
||||
function formatStock(value: number | null): string {
|
||||
if (value === null) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return value.toLocaleString('id-ID');
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<ProductTableRow>[] = [
|
||||
{
|
||||
id: 'row_number',
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
getRowSpan: productRowSpan,
|
||||
cellClassName: 'w-12 max-w-12 shrink-0 text-center',
|
||||
},
|
||||
header: () => h('div', { class: 'text-center' }, 'No.'),
|
||||
cell: ({ row }) => row.original.product_row_number,
|
||||
},
|
||||
{
|
||||
accessorKey: 'product_name',
|
||||
enableSorting: true,
|
||||
meta: {
|
||||
getRowSpan: productRowSpan,
|
||||
cellClassName: GROUPED_CELL_CLASS,
|
||||
},
|
||||
header: () => h(DataTableColumnHeader, { title: 'Nama', column: 'name' }),
|
||||
cell: ({ row }) => h('div', { class: 'font-medium' }, row.original.product_name),
|
||||
},
|
||||
{
|
||||
id: 'categories',
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
getRowSpan: productRowSpan,
|
||||
cellClassName: GROUPED_CELL_CLASS,
|
||||
},
|
||||
header: () => h(DataTableColumnHeader, { title: 'Kategori', column: 'categories' }),
|
||||
cell: ({ row }) => {
|
||||
const categories = row.original.categories;
|
||||
|
||||
if (categories.length === 0) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'flex flex-wrap gap-1' },
|
||||
categories.map((category) => h(Badge, { variant: 'outline' }, () => category.name)),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'variant_name',
|
||||
enableSorting: false,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Varian', column: 'variant_name' }),
|
||||
cell: ({ row }) => row.original.variant_name ?? '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'stock',
|
||||
enableSorting: false,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Stok', column: 'stock' }),
|
||||
cell: ({ row }) => h('span', { class: 'tabular-nums' }, formatStock(row.original.stock)),
|
||||
},
|
||||
{
|
||||
id: 'prices',
|
||||
enableSorting: false,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Harga', column: 'prices' }),
|
||||
cell: ({ row }) => {
|
||||
const prices = row.original.prices;
|
||||
|
||||
if (prices.length === 0) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'space-y-0.5 text-xs' },
|
||||
PRICE_TYPES.map((type) => {
|
||||
const price = prices.find((item) => item.type === type);
|
||||
|
||||
if (!price) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return h('div', { class: 'flex items-center justify-between gap-3' }, [
|
||||
h('span', { class: 'text-muted-foreground' }, PRICE_TYPE_LABELS[type]),
|
||||
h('span', { class: 'font-medium tabular-nums' }, price.price_formatted),
|
||||
]);
|
||||
}).filter(Boolean),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'status_toggle',
|
||||
enableSorting: true,
|
||||
meta: {
|
||||
getRowSpan: productRowSpan,
|
||||
cellClassName: GROUPED_CELL_CLASS,
|
||||
},
|
||||
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'is_active' }),
|
||||
cell: ({ row }) => h(ProductStatusToggle, { row: row.original }),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
getRowSpan: productRowSpan,
|
||||
cellClassName: GROUPED_CELL_CLASS,
|
||||
},
|
||||
cell: ({ row }) => h(DataTableActions, { row: row.original }),
|
||||
},
|
||||
];
|
||||
79
resources/js/components/products/data-table-actions.vue
Normal file
79
resources/js/components/products/data-table-actions.vue
Normal file
@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, router } from '@inertiajs/vue3';
|
||||
import { Pencil, Trash2 } from '@lucide/vue';
|
||||
import { ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import type { ProductTableRow } from '@/types/product';
|
||||
|
||||
const props = defineProps<{
|
||||
row: ProductTableRow;
|
||||
}>();
|
||||
|
||||
const { can } = useCan();
|
||||
|
||||
const deleteConfirmOpen = ref(false);
|
||||
const deleteProcessing = ref(false);
|
||||
|
||||
function destroyProduct() {
|
||||
deleteProcessing.value = true;
|
||||
|
||||
router.delete(`/admin/master/products/${props.row.product_id}`, {
|
||||
onSuccess: () => {
|
||||
deleteConfirmOpen.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menghapus produk.');
|
||||
},
|
||||
onFinish: () => {
|
||||
deleteProcessing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Tooltip v-if="can('products.update')">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="size-8" as-child>
|
||||
<Link :href="`/admin/master/products/${row.product_id}/edit`">
|
||||
<Pencil class="size-4" />
|
||||
<span class="sr-only">Ubah</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Ubah</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip v-if="can('products.delete')">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive hover:text-destructive size-8"
|
||||
@click="deleteConfirmOpen = true"
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
<span class="sr-only">Hapus</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Hapus</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-if="can('products.delete')"
|
||||
v-model:open="deleteConfirmOpen"
|
||||
title="Hapus produk?"
|
||||
:description="`Produk ${row.product_name} akan dihapus beserta seluruh variannya. Tindakan ini tidak dapat dibatalkan.`"
|
||||
confirm-label="Hapus"
|
||||
cancel-label="Batal"
|
||||
destructive
|
||||
:loading="deleteProcessing"
|
||||
@confirm="destroyProduct"
|
||||
/>
|
||||
</template>
|
||||
61
resources/js/components/products/product-status-toggle.vue
Normal file
61
resources/js/components/products/product-status-toggle.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import type { ProductTableRow } from '@/types/product';
|
||||
|
||||
const props = defineProps<{
|
||||
row: ProductTableRow;
|
||||
}>();
|
||||
|
||||
const { can } = useCan();
|
||||
|
||||
const isActive = ref(props.row.is_active);
|
||||
const processing = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.row.is_active,
|
||||
(value) => {
|
||||
isActive.value = value;
|
||||
},
|
||||
);
|
||||
|
||||
function toggleStatus(checked: boolean) {
|
||||
if (!can('products.toggle-status')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = isActive.value;
|
||||
isActive.value = checked;
|
||||
processing.value = true;
|
||||
|
||||
router.patch(`/admin/master/products/${props.row.product_id}/toggle-status`, {
|
||||
is_active: checked,
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
onError: () => {
|
||||
isActive.value = previous;
|
||||
toast.error('Gagal memperbarui status produk.');
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch
|
||||
:model-value="isActive"
|
||||
:disabled="processing || !can('products.toggle-status')"
|
||||
@update:model-value="toggleStatus"
|
||||
/>
|
||||
<Badge :variant="isActive ? 'default' : 'secondary'">
|
||||
{{ isActive ? 'Aktif' : 'Nonaktif' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</template>
|
||||
@ -4,12 +4,14 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
rowspan?: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
:rowspan="rowspan"
|
||||
:class="
|
||||
cn(
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-0.5',
|
||||
|
||||
28
resources/js/lib/products.ts
Normal file
28
resources/js/lib/products.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { ProductTableGroup, ProductTableRow } from '@/types/product';
|
||||
|
||||
export function groupProductRows(rows: ProductTableRow[]): ProductTableGroup[] {
|
||||
const groups: ProductTableGroup[] = [];
|
||||
const indexByProductId = new Map<number, number>();
|
||||
|
||||
for (const row of rows) {
|
||||
const existingIndex = indexByProductId.get(row.product_id);
|
||||
|
||||
if (existingIndex === undefined) {
|
||||
indexByProductId.set(row.product_id, groups.length);
|
||||
groups.push({
|
||||
product_id: row.product_id,
|
||||
product_row_number: row.product_row_number,
|
||||
product_name: row.product_name,
|
||||
categories: row.categories,
|
||||
is_active: row.is_active,
|
||||
variants: [row],
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
groups[existingIndex].variants.push(row);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
42
resources/js/pages/admin/products/Create.vue
Normal file
42
resources/js/pages/admin/products/Create.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { ArrowLeft } from '@lucide/vue';
|
||||
import ProductForm from '@/components/products/ProductForm.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import type { CategoryOption, EnumOption } from '@/types/product';
|
||||
|
||||
defineProps<{
|
||||
categories: CategoryOption[];
|
||||
priceTypes: EnumOption[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Head title="Tambah Produk" />
|
||||
|
||||
<AdminLayout title="Tambah Produk">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-2xl font-bold tracking-tight">
|
||||
Tambah Produk
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" as-child class="shrink-0 self-start sm:self-center">
|
||||
<Link href="/admin/master/products">
|
||||
<ArrowLeft class="size-4" />
|
||||
Kembali
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ProductForm
|
||||
submit-url="/admin/master/products"
|
||||
method="post"
|
||||
submit-label="Simpan"
|
||||
:categories="categories"
|
||||
/>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
52
resources/js/pages/admin/products/Edit.vue
Normal file
52
resources/js/pages/admin/products/Edit.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { ArrowLeft } from '@lucide/vue';
|
||||
import { computed } from 'vue';
|
||||
import ProductForm from '@/components/products/ProductForm.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import type { CategoryOption, EnumOption, ProductEditItem } from '@/types/product';
|
||||
|
||||
const props = defineProps<{
|
||||
product: ProductEditItem;
|
||||
categories: CategoryOption[];
|
||||
priceTypes: EnumOption[];
|
||||
}>();
|
||||
|
||||
const initialData = computed(() => ({
|
||||
name: props.product.name ?? '',
|
||||
description: props.product.description ?? '',
|
||||
category_ids: props.product.category_ids ?? [],
|
||||
variants: props.product.variants ?? [],
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Head title="Ubah Produk" />
|
||||
|
||||
<AdminLayout title="Ubah Produk">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-2xl font-bold tracking-tight">
|
||||
Ubah Produk
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" as-child class="shrink-0 self-start sm:self-center">
|
||||
<Link href="/admin/master/products">
|
||||
<ArrowLeft class="size-4" />
|
||||
Kembali
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ProductForm
|
||||
:submit-url="`/admin/master/products/${product.id}`"
|
||||
method="put"
|
||||
submit-label="Perbarui"
|
||||
:initial-data="initialData"
|
||||
:categories="categories"
|
||||
/>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
111
resources/js/pages/admin/products/Index.vue
Normal file
111
resources/js/pages/admin/products/Index.vue
Normal file
@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { Plus } from '@lucide/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import ProductGroupedTable from '@/components/products/ProductGroupedTable.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import type { DataTableFilterDef } from '@/types/data-table';
|
||||
import type { ProductPagination, ProductTableRow } from '@/types/product';
|
||||
|
||||
const props = defineProps<{
|
||||
rows: ProductTableRow[];
|
||||
pagination: ProductPagination;
|
||||
filters: {
|
||||
search: string;
|
||||
sort?: string;
|
||||
direction?: 'asc' | 'desc';
|
||||
is_active?: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const { can } = useCan();
|
||||
const search = ref(props.filters.search ?? '');
|
||||
|
||||
const { query, setSearch, setFilter, resetFilters, syncFromServer } = useDataTableQuery({
|
||||
url: '/admin/master/products',
|
||||
initial: { ...props.filters },
|
||||
filterKeys: ['is_active'],
|
||||
});
|
||||
|
||||
useDataTableQuerySync(() => props.filters, syncFromServer);
|
||||
|
||||
const filterDefs = computed<DataTableFilterDef[]>(() => [
|
||||
{
|
||||
key: 'is_active',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '1', label: 'Aktif' },
|
||||
{ value: '0', label: 'Nonaktif' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const filterValues = computed(() => ({
|
||||
is_active: query.value.is_active ?? '',
|
||||
}));
|
||||
|
||||
const tablePagination = computed(() => ({
|
||||
currentPage: props.pagination.current_page,
|
||||
perPage: props.pagination.per_page,
|
||||
lastPage: props.pagination.last_page,
|
||||
total: props.pagination.total,
|
||||
}));
|
||||
|
||||
const productCountOnPage = computed(() => (
|
||||
new Set(props.rows.map((row) => row.product_id)).size
|
||||
));
|
||||
|
||||
watch(search, (value) => {
|
||||
setSearch(value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.filters.search,
|
||||
(value) => {
|
||||
search.value = value ?? '';
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Head title="Produk" />
|
||||
|
||||
<AdminLayout>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-2xl font-bold tracking-tight">
|
||||
Produk
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Button v-if="can('products.create')" as-child class="shrink-0 self-start sm:self-center">
|
||||
<Link href="/admin/master/products/create">
|
||||
<Plus class="size-4" />
|
||||
Tambah
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card class="min-w-0">
|
||||
<CardContent class="min-w-0">
|
||||
<ProductGroupedTable
|
||||
v-model:search="search"
|
||||
:rows="rows"
|
||||
:pagination="tablePagination"
|
||||
:pagination-links="pagination.links"
|
||||
:pagination-displayed-count="productCountOnPage"
|
||||
:filter-defs="filterDefs"
|
||||
:filter-values="filterValues"
|
||||
@filter-change="setFilter"
|
||||
@filters-reset="resetFilters"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
122
resources/js/types/product.ts
Normal file
122
resources/js/types/product.ts
Normal file
@ -0,0 +1,122 @@
|
||||
export type EnumOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type CategoryOption = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ProductPriceItem = {
|
||||
type: string;
|
||||
type_label: string;
|
||||
price: string;
|
||||
price_formatted: string;
|
||||
};
|
||||
|
||||
export type ProductCategoryItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ProductTableGroup = {
|
||||
product_id: number;
|
||||
product_row_number: number;
|
||||
product_name: string;
|
||||
categories: ProductCategoryItem[];
|
||||
is_active: boolean;
|
||||
variants: ProductTableRow[];
|
||||
};
|
||||
|
||||
export type ProductTableRow = {
|
||||
row_id: string;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
categories: ProductCategoryItem[];
|
||||
is_active: boolean;
|
||||
is_first_variant: boolean;
|
||||
variant_count: number;
|
||||
product_row_number: number;
|
||||
variant_id: number | null;
|
||||
variant_name: string | null;
|
||||
stock: number | null;
|
||||
prices: ProductPriceItem[];
|
||||
};
|
||||
|
||||
export type ProductVariantFormItem = {
|
||||
client_id: string;
|
||||
id?: number;
|
||||
name: string;
|
||||
stock: string;
|
||||
prices: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ProductEditItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category_ids: number[];
|
||||
variants: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
stock: number;
|
||||
prices: Record<string, string>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ProductFormData = {
|
||||
name: string;
|
||||
description: string;
|
||||
category_ids: number[];
|
||||
variants: Array<{
|
||||
id?: number;
|
||||
name: string;
|
||||
stock: number;
|
||||
prices: Array<{
|
||||
type: string;
|
||||
price: number;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ProductFilters = {
|
||||
search: string;
|
||||
sort?: string;
|
||||
direction?: 'asc' | 'desc' | null;
|
||||
is_active?: string;
|
||||
};
|
||||
|
||||
export type ProductPagination = {
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
links: Array<{
|
||||
url: string | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const PRICE_TYPES = [
|
||||
'distributor',
|
||||
'agent',
|
||||
'sub_agent',
|
||||
'grosir',
|
||||
'ecer',
|
||||
'tiktok',
|
||||
'shopee',
|
||||
] as const;
|
||||
|
||||
export type PriceType = (typeof PRICE_TYPES)[number];
|
||||
|
||||
export const PRICE_TYPE_LABELS: Record<PriceType, string> = {
|
||||
distributor: 'Distributor',
|
||||
agent: 'Agen',
|
||||
sub_agent: 'Sub Agen',
|
||||
grosir: 'Grosir',
|
||||
ecer: 'Eceran',
|
||||
tiktok: 'TikTok',
|
||||
shopee: 'Shopee',
|
||||
};
|
||||
9
resources/js/types/tanstack-table.d.ts
vendored
Normal file
9
resources/js/types/tanstack-table.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
import '@tanstack/vue-table';
|
||||
|
||||
declare module '@tanstack/vue-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData, TValue> {
|
||||
getRowSpan?: (row: TData) => number;
|
||||
cellClassName?: string;
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
use App\Http\Controllers\Admin\CategoryController;
|
||||
use App\Http\Controllers\Admin\DashboardController;
|
||||
use App\Http\Controllers\Admin\Hr\EmployeeController;
|
||||
use App\Http\Controllers\Admin\ProductController;
|
||||
use App\Http\Controllers\Auth\LoginController;
|
||||
use App\Http\Controllers\Auth\LogoutController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@ -22,27 +23,55 @@
|
||||
Route::get('/dashboard', [DashboardController::class, 'index'])
|
||||
->name('dashboard');
|
||||
|
||||
Route::prefix('master')->name('master.')
|
||||
->middleware('permission:'.Permission::CATEGORIES_VIEW->value)
|
||||
->group(function () {
|
||||
Route::prefix('categories')->name('categories.')
|
||||
->middleware('permission:'.Permission::CATEGORIES_VIEW->value)
|
||||
->group(function () {
|
||||
Route::get('/', [CategoryController::class, 'index'])->name('index');
|
||||
Route::prefix('master')->name('master.')->group(function () {
|
||||
Route::prefix('categories')->name('categories.')
|
||||
->middleware('permission:'.Permission::CATEGORIES_VIEW->value)
|
||||
->group(function () {
|
||||
Route::get('/', [CategoryController::class, 'index'])->name('index');
|
||||
|
||||
Route::post('/', [CategoryController::class, 'store'])
|
||||
->middleware('permission:'.Permission::CATEGORIES_CREATE->value)
|
||||
->name('store');
|
||||
Route::post('/', [CategoryController::class, 'store'])
|
||||
->middleware('permission:'.Permission::CATEGORIES_CREATE->value)
|
||||
->name('store');
|
||||
|
||||
Route::put('{category}', [CategoryController::class, 'update'])
|
||||
->middleware('permission:'.Permission::CATEGORIES_UPDATE->value)
|
||||
->name('update');
|
||||
Route::put('{category}', [CategoryController::class, 'update'])
|
||||
->middleware('permission:'.Permission::CATEGORIES_UPDATE->value)
|
||||
->name('update');
|
||||
|
||||
Route::delete('{category}', [CategoryController::class, 'destroy'])
|
||||
->middleware('permission:'.Permission::CATEGORIES_DELETE->value)
|
||||
->name('destroy');
|
||||
});
|
||||
});
|
||||
Route::delete('{category}', [CategoryController::class, 'destroy'])
|
||||
->middleware('permission:'.Permission::CATEGORIES_DELETE->value)
|
||||
->name('destroy');
|
||||
});
|
||||
|
||||
Route::prefix('products')->name('products.')
|
||||
->middleware('permission:'.Permission::PRODUCTS_VIEW->value)
|
||||
->group(function () {
|
||||
Route::get('create', [ProductController::class, 'create'])
|
||||
->middleware('permission:'.Permission::PRODUCTS_CREATE->value)
|
||||
->name('create');
|
||||
|
||||
Route::post('/', [ProductController::class, 'store'])
|
||||
->middleware('permission:'.Permission::PRODUCTS_CREATE->value)
|
||||
->name('store');
|
||||
|
||||
Route::get('{product}/edit', [ProductController::class, 'edit'])
|
||||
->middleware('permission:'.Permission::PRODUCTS_UPDATE->value)
|
||||
->name('edit');
|
||||
|
||||
Route::put('{product}', [ProductController::class, 'update'])
|
||||
->middleware('permission:'.Permission::PRODUCTS_UPDATE->value)
|
||||
->name('update');
|
||||
|
||||
Route::patch('{product}/toggle-status', [ProductController::class, 'toggleStatus'])
|
||||
->middleware('permission:'.Permission::PRODUCTS_TOGGLE_STATUS->value)
|
||||
->name('toggle-status');
|
||||
|
||||
Route::delete('{product}', [ProductController::class, 'destroy'])
|
||||
->middleware('permission:'.Permission::PRODUCTS_DELETE->value)
|
||||
->name('destroy');
|
||||
|
||||
Route::get('/', [ProductController::class, 'index'])->name('index');
|
||||
});
|
||||
});
|
||||
|
||||
Route::prefix('hr')->name('hr.')->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)->group(function () {
|
||||
Route::prefix('employees')->name('employees.')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user