feat: implement product management module with CRUD operations, validation, and UI integration
This commit is contained in:
parent
bcd08e28ed
commit
4b5d5af316
23
app/Enums/PriceType.php
Normal file
23
app/Enums/PriceType.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum PriceType: string
|
||||||
|
{
|
||||||
|
case PURCHASE = 'purchase';
|
||||||
|
case DISTRIBUTOR = 'distributor';
|
||||||
|
case AGENT = 'agent';
|
||||||
|
case RESELLER = 'reseller';
|
||||||
|
case RETAIL = 'retail';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::PURCHASE => 'Beli',
|
||||||
|
self::DISTRIBUTOR => 'Distributor',
|
||||||
|
self::AGENT => 'Agen',
|
||||||
|
self::RESELLER => 'Reseller',
|
||||||
|
self::RETAIL => 'Retail',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
118
app/Http/Controllers/Admin/Master/ProductController.php
Normal file
118
app/Http/Controllers/Admin/Master/ProductController.php
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Master;
|
||||||
|
|
||||||
|
use App\Enums\PriceType;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Admin\Master\ProductRequest;
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\Product;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ProductController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('admin/master/product/index', [
|
||||||
|
'products' => Product::with(['prices', 'categories'])->latest()->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('admin/master/product/create', [
|
||||||
|
'categories' => Category::active()->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ProductRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($validated) {
|
||||||
|
$product = Product::create([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'description' => $validated['description'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$product->categories()->sync($validated['category_ids'] ?? []);
|
||||||
|
|
||||||
|
foreach ($validated['prices'] as $type => $price) {
|
||||||
|
$enumType = PriceType::tryFrom($type);
|
||||||
|
if ($enumType) {
|
||||||
|
$product->prices()->create([
|
||||||
|
'price_type' => $enumType,
|
||||||
|
'price' => $price,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()->route('product.index')->with('success', 'Data berhasil disimpan');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Product $product): Response
|
||||||
|
{
|
||||||
|
$product->load(['prices', 'categories']);
|
||||||
|
|
||||||
|
return Inertia::render('admin/master/product/edit', [
|
||||||
|
'product' => $product,
|
||||||
|
'categories' => Category::active()->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ProductRequest $request, Product $product): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($product, $validated) {
|
||||||
|
$product->update([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'description' => $validated['description'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$product->categories()->sync($validated['category_ids'] ?? []);
|
||||||
|
|
||||||
|
foreach ($validated['prices'] as $type => $price) {
|
||||||
|
$enumType = PriceType::tryFrom($type);
|
||||||
|
if ($enumType) {
|
||||||
|
$product->prices()->updateOrCreate(
|
||||||
|
['price_type' => $enumType],
|
||||||
|
['price' => $price]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()->route('product.index')->with('success', 'Data berhasil diperbarui');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Product $product): RedirectResponse
|
||||||
|
{
|
||||||
|
$product->delete();
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', 'Data berhasil dihapus');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkDestroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$ids = $request->input('ids');
|
||||||
|
|
||||||
|
Product::whereIn('id', $ids)->delete();
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', 'Data terpilih berhasil dihapus');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleStatus(Product $product): RedirectResponse
|
||||||
|
{
|
||||||
|
$product->update([
|
||||||
|
'is_active' => ! $product->is_active,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', 'Status berhasil diperbarui');
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Http/Requests/Admin/Master/ProductRequest.php
Normal file
40
app/Http/Requests/Admin/Master/ProductRequest.php
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin\Master;
|
||||||
|
|
||||||
|
use App\Models\Category;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class ProductRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'category_ids' => ['required', 'array'],
|
||||||
|
'category_ids.*' => Rule::exists(Category::class, 'id')->where('is_active', true),
|
||||||
|
'prices' => ['required', 'array'],
|
||||||
|
'prices.purchase' => ['required', 'integer', 'min:0'],
|
||||||
|
'prices.distributor' => ['required', 'integer', 'min:0'],
|
||||||
|
'prices.agent' => ['required', 'integer', 'min:0'],
|
||||||
|
'prices.reseller' => ['required', 'integer', 'min:0'],
|
||||||
|
'prices.retail' => ['required', 'integer', 'min:0'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Spatie\Sluggable\HasSlug;
|
use Spatie\Sluggable\HasSlug;
|
||||||
use Spatie\Sluggable\SlugOptions;
|
use Spatie\Sluggable\SlugOptions;
|
||||||
@ -21,10 +24,27 @@ protected function casts(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Scope]
|
||||||
|
protected function active(Builder $query): void
|
||||||
|
{
|
||||||
|
$query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Scope]
|
||||||
|
protected function inactive(Builder $query): void
|
||||||
|
{
|
||||||
|
$query->where('is_active', false);
|
||||||
|
}
|
||||||
|
|
||||||
public function getSlugOptions(): SlugOptions
|
public function getSlugOptions(): SlugOptions
|
||||||
{
|
{
|
||||||
return SlugOptions::create()
|
return SlugOptions::create()
|
||||||
->generateSlugsFrom('title')
|
->generateSlugsFrom('title')
|
||||||
->saveSlugsTo('slug');
|
->saveSlugsTo('slug');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function products(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Product::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
56
app/Models/Product.php
Normal file
56
app/Models/Product.php
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
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\HasSlug;
|
||||||
|
use Spatie\Sluggable\SlugOptions;
|
||||||
|
|
||||||
|
class Product extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, HasSlug, SoftDeletes;
|
||||||
|
|
||||||
|
protected $guarded = ['id'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Scope]
|
||||||
|
protected function active(Builder $query): void
|
||||||
|
{
|
||||||
|
$query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Scope]
|
||||||
|
protected function inactive(Builder $query): void
|
||||||
|
{
|
||||||
|
$query->where('is_active', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSlugOptions(): SlugOptions
|
||||||
|
{
|
||||||
|
return SlugOptions::create()
|
||||||
|
->generateSlugsFrom('name')
|
||||||
|
->saveSlugsTo('slug');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prices(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ProductPrice::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function categories(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Category::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\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class ProductPrice extends Model
|
||||||
|
{
|
||||||
|
protected $guarded = ['id'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'price_type' => PriceType::class,
|
||||||
|
'price' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function product(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Product::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
database/factories/ProductFactory.php
Normal file
49
database/factories/ProductFactory.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\PriceType;
|
||||||
|
use App\Models\Product;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Product>
|
||||||
|
*/
|
||||||
|
class ProductFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$types = ['Gamis', 'Dress', 'Tunik', 'Abaya', 'Hijab', 'Setelan', 'Blazer', 'Outer', 'Kaftan'];
|
||||||
|
$adjectives = ['Syari', 'Premium', 'Basic', 'Modern', 'Casual', 'Elegan', 'Mewah', 'Daily'];
|
||||||
|
$name = $this->faker->randomElement($types).' '.$this->faker->randomElement($adjectives).' '.$this->faker->firstNameFemale;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $name,
|
||||||
|
'description' => $this->faker->paragraph,
|
||||||
|
'is_active' => $this->faker->boolean(80),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the model factory.
|
||||||
|
*/
|
||||||
|
public function configure(): static
|
||||||
|
{
|
||||||
|
return $this->afterCreating(function (Product $product) {
|
||||||
|
$basePrice = $this->faker->numberBetween(5, 30) * 10000;
|
||||||
|
|
||||||
|
$product->prices()->createMany([
|
||||||
|
['price_type' => PriceType::PURCHASE->value, 'price' => $basePrice],
|
||||||
|
['price_type' => PriceType::DISTRIBUTOR->value, 'price' => $basePrice * 1.1],
|
||||||
|
['price_type' => PriceType::AGENT->value, 'price' => $basePrice * 1.25],
|
||||||
|
['price_type' => PriceType::RESELLER->value, 'price' => $basePrice * 1.4],
|
||||||
|
['price_type' => PriceType::RETAIL->value, 'price' => $basePrice * 1.6],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('products', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name', 100);
|
||||||
|
$table->string('slug', 100);
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('products');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\PriceType;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('product_prices', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->enum('price_type', PriceType::cases());
|
||||||
|
$table->unsignedInteger('price');
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('product_prices');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('category_product', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('category_product');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -16,6 +16,7 @@ public function run(): void
|
|||||||
$this->call([
|
$this->call([
|
||||||
UserSeeder::class,
|
UserSeeder::class,
|
||||||
CategorySeeder::class,
|
CategorySeeder::class,
|
||||||
|
ProductSeeder::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
100
database/seeders/ProductSeeder.php
Normal file
100
database/seeders/ProductSeeder.php
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Enums\PriceType;
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\Product;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class ProductSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$categoryIds = Category::pluck('id')->toArray();
|
||||||
|
$products = [
|
||||||
|
[
|
||||||
|
'name' => 'Gamis Syari Khadijah',
|
||||||
|
'description' => 'Gamis syari berbahan wolfis premium dengan potongan elegan.',
|
||||||
|
'is_active' => true,
|
||||||
|
'prices' => [
|
||||||
|
PriceType::PURCHASE->value => 120000,
|
||||||
|
PriceType::DISTRIBUTOR->value => 140000,
|
||||||
|
PriceType::AGENT->value => 160000,
|
||||||
|
PriceType::RESELLER->value => 180000,
|
||||||
|
PriceType::RETAIL->value => 210000,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Dress Brokat Aisyah',
|
||||||
|
'description' => 'Dress dengan balutan brokat mewah cocok untuk kondangan.',
|
||||||
|
'is_active' => true,
|
||||||
|
'prices' => [
|
||||||
|
PriceType::PURCHASE->value => 150000,
|
||||||
|
PriceType::DISTRIBUTOR->value => 170000,
|
||||||
|
PriceType::AGENT->value => 195000,
|
||||||
|
PriceType::RESELLER->value => 220000,
|
||||||
|
PriceType::RETAIL->value => 250000,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Tunik Muslimah Daily',
|
||||||
|
'description' => 'Tunik kasual berbahan katun rayon yang nyaman dipakai sehari-hari.',
|
||||||
|
'is_active' => true,
|
||||||
|
'prices' => [
|
||||||
|
PriceType::PURCHASE->value => 80000,
|
||||||
|
PriceType::DISTRIBUTOR->value => 95000,
|
||||||
|
PriceType::AGENT->value => 110000,
|
||||||
|
PriceType::RESELLER->value => 130000,
|
||||||
|
PriceType::RETAIL->value => 150000,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Abaya Dubai Hitam',
|
||||||
|
'description' => 'Abaya gaya timur tengah dengan bordir benang emas yang anggun.',
|
||||||
|
'is_active' => true,
|
||||||
|
'prices' => [
|
||||||
|
PriceType::PURCHASE->value => 180000,
|
||||||
|
PriceType::DISTRIBUTOR->value => 210000,
|
||||||
|
PriceType::AGENT->value => 240000,
|
||||||
|
PriceType::RESELLER->value => 270000,
|
||||||
|
PriceType::RETAIL->value => 310000,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Setelan Kulot Fatima',
|
||||||
|
'description' => 'Setelan atasan asimetris dan celana kulot dengan desain modern.',
|
||||||
|
'is_active' => true,
|
||||||
|
'prices' => [
|
||||||
|
PriceType::PURCHASE->value => 135000,
|
||||||
|
PriceType::DISTRIBUTOR->value => 155000,
|
||||||
|
PriceType::AGENT->value => 175000,
|
||||||
|
PriceType::RESELLER->value => 195000,
|
||||||
|
PriceType::RETAIL->value => 230000,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($products as $data) {
|
||||||
|
$prices = $data['prices'];
|
||||||
|
unset($data['prices']);
|
||||||
|
|
||||||
|
$product = Product::create($data);
|
||||||
|
|
||||||
|
if (! empty($categoryIds)) {
|
||||||
|
$randomCategories = collect($categoryIds)->random(rand(1, min(3, count($categoryIds))))->toArray();
|
||||||
|
$product->categories()->sync($randomCategories);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($prices as $type => $price) {
|
||||||
|
$product->prices()->create([
|
||||||
|
'price_type' => $type,
|
||||||
|
'price' => $price,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
import { LayoutGrid, List } from 'lucide-react';
|
import { Boxes, LayoutGrid, List } from 'lucide-react';
|
||||||
import AppLogo from '@/components/app-logo';
|
import AppLogo from '@/components/app-logo';
|
||||||
import { NavMain } from '@/components/nav-main';
|
import { NavMain } from '@/components/nav-main';
|
||||||
import {
|
import {
|
||||||
@ -14,6 +14,7 @@ import { dashboard } from '@/routes';
|
|||||||
import category from '@/routes/category';
|
import category from '@/routes/category';
|
||||||
|
|
||||||
import type { NavItem } from '@/types';
|
import type { NavItem } from '@/types';
|
||||||
|
import product from '@/routes/product';
|
||||||
|
|
||||||
const mainNavItems: NavItem[] = [
|
const mainNavItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
@ -29,6 +30,11 @@ const masterNavItems: NavItem[] = [
|
|||||||
href: category.index().url,
|
href: category.index().url,
|
||||||
icon: List,
|
icon: List,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Produk',
|
||||||
|
href: product.index().url,
|
||||||
|
icon: Boxes,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
|
|||||||
221
resources/js/pages/admin/master/product/create.tsx
Normal file
221
resources/js/pages/admin/master/product/create.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import { Head, useForm, Link } from '@inertiajs/react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Field, FieldGroup } from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import productRoutes from '@/routes/product';
|
||||||
|
import React from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor'
|
||||||
|
import { Category } from '@/types/category';
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxEmpty,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxList,
|
||||||
|
ComboboxChips,
|
||||||
|
ComboboxChip,
|
||||||
|
ComboboxChipsInput,
|
||||||
|
ComboboxValue,
|
||||||
|
useComboboxAnchor,
|
||||||
|
} from "@/components/ui/combobox"
|
||||||
|
|
||||||
|
export default function ProductCreate({ categories }: { categories: Category[] }) {
|
||||||
|
const { data, setData, post, processing, errors } = useForm({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
category_ids: [] as number[],
|
||||||
|
prices: {
|
||||||
|
purchase: '',
|
||||||
|
distributor: '',
|
||||||
|
agent: '',
|
||||||
|
reseller: '',
|
||||||
|
retail: '',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const anchor = useComboboxAnchor()
|
||||||
|
|
||||||
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
post(productRoutes.store().url, {
|
||||||
|
onSuccess: (response: any) => {
|
||||||
|
toast.success(response.props.flash.success);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredCategories = categories.filter((c) => !data.category_ids.includes(c.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
<Head title="Tambah Produk" />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Tambah Produk</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm p-5">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<form onSubmit={onSubmit} className="space-y-6 mt-4">
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<Label htmlFor="name">Nama</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value={data.name}
|
||||||
|
onChange={e => setData('name', e.target.value)}
|
||||||
|
autoComplete='off'
|
||||||
|
placeholder='Contoh: Gamis Wanita'
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Field>
|
||||||
|
<Label>Kategori</Label>
|
||||||
|
<Combobox
|
||||||
|
multiple
|
||||||
|
autoHighlight
|
||||||
|
items={filteredCategories}
|
||||||
|
value={categories.filter(c => data.category_ids.includes(c.id))}
|
||||||
|
onValueChange={(selected) => {
|
||||||
|
setData(
|
||||||
|
"category_ids",
|
||||||
|
selected.map((item: Category) => item.id)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ComboboxChips ref={anchor} className="w-full">
|
||||||
|
<ComboboxValue>
|
||||||
|
{(values: Category[]) => (
|
||||||
|
<>
|
||||||
|
{values.map((value) => (
|
||||||
|
<ComboboxChip key={value.id}>
|
||||||
|
{value.title}
|
||||||
|
</ComboboxChip>
|
||||||
|
))}
|
||||||
|
<ComboboxChipsInput placeholder='Pilih Kategori' />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ComboboxValue>
|
||||||
|
</ComboboxChips>
|
||||||
|
<ComboboxContent anchor={anchor}>
|
||||||
|
<ComboboxEmpty>Data tidak ditemukan.</ComboboxEmpty>
|
||||||
|
<ComboboxList>
|
||||||
|
{(item: Category) => (
|
||||||
|
<ComboboxItem key={item.id} value={item}>
|
||||||
|
{item.title}
|
||||||
|
</ComboboxItem>
|
||||||
|
)}
|
||||||
|
</ComboboxList>
|
||||||
|
</ComboboxContent>
|
||||||
|
</Combobox>
|
||||||
|
{errors.category_ids && <p className="text-xs text-red-500">{errors.category_ids}</p>}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label htmlFor="price_purchase">Harga Beli</Label>
|
||||||
|
<Input
|
||||||
|
id="price_purchase"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={data.prices.purchase}
|
||||||
|
onChange={e => setData('prices', { ...data.prices, purchase: e.target.value })}
|
||||||
|
placeholder='0'
|
||||||
|
/>
|
||||||
|
{errors['prices.purchase'] && <p className="text-xs text-red-500">{errors['prices.purchase']}</p>}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label htmlFor="price_distributor">Harga Distributor</Label>
|
||||||
|
<Input
|
||||||
|
id="price_distributor"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={data.prices.distributor}
|
||||||
|
onChange={e => setData('prices', { ...data.prices, distributor: e.target.value })}
|
||||||
|
placeholder='0'
|
||||||
|
/>
|
||||||
|
{errors['prices.distributor'] && <p className="text-xs text-red-500">{errors['prices.distributor']}</p>}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label htmlFor="price_agent">Harga Agen</Label>
|
||||||
|
<Input
|
||||||
|
id="price_agent"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={data.prices.agent}
|
||||||
|
onChange={e => setData('prices', { ...data.prices, agent: e.target.value })}
|
||||||
|
placeholder='0'
|
||||||
|
/>
|
||||||
|
{errors['prices.agent'] && <p className="text-xs text-red-500">{errors['prices.agent']}</p>}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label htmlFor="price_reseller">Harga Reseller</Label>
|
||||||
|
<Input
|
||||||
|
id="price_reseller"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={data.prices.reseller}
|
||||||
|
onChange={e => setData('prices', { ...data.prices, reseller: e.target.value })}
|
||||||
|
placeholder='0'
|
||||||
|
/>
|
||||||
|
{errors['prices.reseller'] && <p className="text-xs text-red-500">{errors['prices.reseller']}</p>}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label htmlFor="price_retail">Harga Retail</Label>
|
||||||
|
<Input
|
||||||
|
id="price_retail"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={data.prices.retail}
|
||||||
|
onChange={e => setData('prices', { ...data.prices, retail: e.target.value })}
|
||||||
|
placeholder='0'
|
||||||
|
/>
|
||||||
|
{errors['prices.retail'] && <p className="text-xs text-red-500">{errors['prices.retail']}</p>}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label htmlFor="description">Deskripsi</Label>
|
||||||
|
<SimpleEditor
|
||||||
|
value={data.description}
|
||||||
|
onChange={(val) => setData('description', val)}
|
||||||
|
/>
|
||||||
|
{errors.description && <p className="text-xs text-red-500">{errors.description}</p>}
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<div className="flex gap-4 p-4 mt-6 rounded-lg justify-end">
|
||||||
|
<Link href={productRoutes.index().url}>
|
||||||
|
<Button type="button" variant="outline">Kembali</Button>
|
||||||
|
</Link>
|
||||||
|
<Button type="submit" disabled={processing}>
|
||||||
|
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProductCreate.layout = {
|
||||||
|
breadcrumbs: [
|
||||||
|
{
|
||||||
|
title: 'Master',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
229
resources/js/pages/admin/master/product/edit.tsx
Normal file
229
resources/js/pages/admin/master/product/edit.tsx
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import { Head, useForm, Link } from '@inertiajs/react';
|
||||||
|
import type { Product, ProductPrice } from '@/types';
|
||||||
|
import { Category } from '@/types/category';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Field, FieldGroup } from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import productRoutes from '@/routes/product';
|
||||||
|
import React from 'react';
|
||||||
|
import { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor';
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxEmpty,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxList,
|
||||||
|
ComboboxChips,
|
||||||
|
ComboboxChip,
|
||||||
|
ComboboxChipsInput,
|
||||||
|
ComboboxValue,
|
||||||
|
useComboboxAnchor,
|
||||||
|
} from "@/components/ui/combobox"
|
||||||
|
|
||||||
|
export default function ProductEdit({ product, categories }: { product: Product, categories: Category[] }) {
|
||||||
|
const getPrice = (type: string) => {
|
||||||
|
const priceObj = product.prices?.find((p: ProductPrice) => p.price_type === type);
|
||||||
|
return priceObj ? priceObj.price.toString() : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialCategoryIds = product.categories?.map(c => c.id) || [];
|
||||||
|
|
||||||
|
const { data, setData, patch, processing, errors } = useForm({
|
||||||
|
name: product.name || '',
|
||||||
|
description: product.description || '',
|
||||||
|
category_ids: initialCategoryIds,
|
||||||
|
prices: {
|
||||||
|
purchase: getPrice('purchase'),
|
||||||
|
distributor: getPrice('distributor'),
|
||||||
|
agent: getPrice('agent'),
|
||||||
|
reseller: getPrice('reseller'),
|
||||||
|
retail: getPrice('retail'),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const anchor = useComboboxAnchor()
|
||||||
|
|
||||||
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
patch(productRoutes.update(product.id).url, {
|
||||||
|
onSuccess: (response: any) => {
|
||||||
|
toast.success(response.props.flash.success);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredCategories = categories.filter((c) => !data.category_ids.includes(c.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
<Head title={`Ubah Produk: ${product.name}`} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Ubah Produk</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm p-5">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<form onSubmit={onSubmit} className="space-y-6 mt-4">
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<Label htmlFor="name">Nama</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value={data.name}
|
||||||
|
onChange={e => setData('name', e.target.value)}
|
||||||
|
autoComplete='off'
|
||||||
|
placeholder='Contoh: Gamis Wanita'
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Field>
|
||||||
|
<Label>Kategori</Label>
|
||||||
|
<Combobox
|
||||||
|
multiple
|
||||||
|
autoHighlight
|
||||||
|
items={filteredCategories}
|
||||||
|
value={categories.filter(c => data.category_ids.includes(c.id))}
|
||||||
|
onValueChange={(selected) => {
|
||||||
|
setData(
|
||||||
|
"category_ids",
|
||||||
|
selected.map((item: Category) => item.id)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ComboboxChips ref={anchor} className="w-full">
|
||||||
|
<ComboboxValue>
|
||||||
|
{(values: Category[]) => (
|
||||||
|
<>
|
||||||
|
{values.map((value) => (
|
||||||
|
<ComboboxChip key={value.id}>
|
||||||
|
{value.title}
|
||||||
|
</ComboboxChip>
|
||||||
|
))}
|
||||||
|
<ComboboxChipsInput placeholder='Pilih Kategori' />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ComboboxValue>
|
||||||
|
</ComboboxChips>
|
||||||
|
<ComboboxContent anchor={anchor}>
|
||||||
|
<ComboboxEmpty>Data tidak ditemukan.</ComboboxEmpty>
|
||||||
|
<ComboboxList>
|
||||||
|
{(item: Category) => (
|
||||||
|
<ComboboxItem key={item.id} value={item}>
|
||||||
|
{item.title}
|
||||||
|
</ComboboxItem>
|
||||||
|
)}
|
||||||
|
</ComboboxList>
|
||||||
|
</ComboboxContent>
|
||||||
|
</Combobox>
|
||||||
|
{errors.category_ids && <p className="text-xs text-red-500">{errors.category_ids}</p>}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label htmlFor="price_purchase">Harga Beli</Label>
|
||||||
|
<Input
|
||||||
|
id="price_purchase"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={data.prices.purchase}
|
||||||
|
onChange={e => setData('prices', { ...data.prices, purchase: e.target.value })}
|
||||||
|
placeholder='0'
|
||||||
|
/>
|
||||||
|
{errors['prices.purchase'] && <p className="text-xs text-red-500">{errors['prices.purchase']}</p>}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label htmlFor="price_distributor">Harga Distributor</Label>
|
||||||
|
<Input
|
||||||
|
id="price_distributor"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={data.prices.distributor}
|
||||||
|
onChange={e => setData('prices', { ...data.prices, distributor: e.target.value })}
|
||||||
|
placeholder='0'
|
||||||
|
/>
|
||||||
|
{errors['prices.distributor'] && <p className="text-xs text-red-500">{errors['prices.distributor']}</p>}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label htmlFor="price_agent">Harga Agen</Label>
|
||||||
|
<Input
|
||||||
|
id="price_agent"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={data.prices.agent}
|
||||||
|
onChange={e => setData('prices', { ...data.prices, agent: e.target.value })}
|
||||||
|
placeholder='0'
|
||||||
|
/>
|
||||||
|
{errors['prices.agent'] && <p className="text-xs text-red-500">{errors['prices.agent']}</p>}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label htmlFor="price_reseller">Harga Reseller</Label>
|
||||||
|
<Input
|
||||||
|
id="price_reseller"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={data.prices.reseller}
|
||||||
|
onChange={e => setData('prices', { ...data.prices, reseller: e.target.value })}
|
||||||
|
placeholder='0'
|
||||||
|
/>
|
||||||
|
{errors['prices.reseller'] && <p className="text-xs text-red-500">{errors['prices.reseller']}</p>}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label htmlFor="price_retail">Harga Retail</Label>
|
||||||
|
<Input
|
||||||
|
id="price_retail"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={data.prices.retail}
|
||||||
|
onChange={e => setData('prices', { ...data.prices, retail: e.target.value })}
|
||||||
|
placeholder='0'
|
||||||
|
/>
|
||||||
|
{errors['prices.retail'] && <p className="text-xs text-red-500">{errors['prices.retail']}</p>}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label htmlFor="description">Deskripsi</Label>
|
||||||
|
<SimpleEditor
|
||||||
|
value={data.description}
|
||||||
|
onChange={(val) => setData('description', val)}
|
||||||
|
/>
|
||||||
|
{errors.description && <p className="text-xs text-red-500">{errors.description}</p>}
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<div className="flex gap-4 p-4 mt-6 rounded-lg justify-end">
|
||||||
|
<Link href={productRoutes.index().url}>
|
||||||
|
<Button type="button" variant="outline">Kembali</Button>
|
||||||
|
</Link>
|
||||||
|
<Button type="submit" disabled={processing}>
|
||||||
|
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProductEdit.layout = {
|
||||||
|
breadcrumbs: [
|
||||||
|
{
|
||||||
|
title: 'Master',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
254
resources/js/pages/admin/master/product/index.tsx
Normal file
254
resources/js/pages/admin/master/product/index.tsx
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
import { Head, router, Link } from '@inertiajs/react';
|
||||||
|
import type { Product } from '@/types';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Trash2, Pencil, Plus } from 'lucide-react';
|
||||||
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { TooltipContent, TooltipTrigger } from '@radix-ui/react-tooltip';
|
||||||
|
import { Tooltip } from '@/components/ui/tooltip';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import productRoutes from '@/routes/product';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
|
||||||
|
export default function ProductIndex({ products }: { products: Product[] }) {
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||||
|
const [productToDelete, setProductToDelete] = useState<Product | null>(null);
|
||||||
|
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
|
||||||
|
const [rowSelection, setRowSelection] = useState({});
|
||||||
|
|
||||||
|
const onDelete = (product: Product) => {
|
||||||
|
setProductToDelete(product);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (productToDelete) {
|
||||||
|
router.delete(productRoutes.destroy(productToDelete.id).url, {
|
||||||
|
onSuccess: (response: any) => {
|
||||||
|
toast.success(response.props.flash.success);
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setProductToDelete(null);
|
||||||
|
setRowSelection({});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmBulkDelete = () => {
|
||||||
|
router.post(productRoutes.bulkDestroy().url, {
|
||||||
|
ids: rowsToDelete.map((row: any) => row.id),
|
||||||
|
_method: 'DELETE'
|
||||||
|
}, {
|
||||||
|
onSuccess: (response: any) => {
|
||||||
|
toast.success(response.props.flash.success);
|
||||||
|
setIsBulkDeleteDialogOpen(false);
|
||||||
|
setRowsToDelete([]);
|
||||||
|
setRowSelection({});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggleStatus = (id: number) => {
|
||||||
|
router.patch(productRoutes.toggleStatus(id).url, {}, {
|
||||||
|
onSuccess: (response: any) => toast.success(response.props.flash.success),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<Product>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<DataTableColumnHeader column={column} title="Nama" />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { title: "Nama" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "categories",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<DataTableColumnHeader column={column} title="Kategori" />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { title: "Kategori" },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const product = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{product.categories?.map((category) => (
|
||||||
|
<Badge key={category.id} variant="secondary">
|
||||||
|
{category.title}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{(!product.categories || product.categories.length === 0) && (
|
||||||
|
<span className="text-muted-foreground text-xs italic">Tanpa Kategori</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "is_active",
|
||||||
|
header: "Status",
|
||||||
|
meta: { title: "Status" },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const product = row.original;
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
checked={product.is_active}
|
||||||
|
onCheckedChange={() => onToggleStatus(product.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "Aksi",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const product = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Link href={productRoutes.edit(product.id).url}>
|
||||||
|
<Button variant="ghost" size="icon" className='text-yellow-600'>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Ubah</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className='text-red-600' onClick={() => onDelete(product)}>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Hapus</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: { title: "Aksi" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
<Head title="Produk" />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Produk</h1>
|
||||||
|
</div>
|
||||||
|
<Link href={productRoutes.create().url}>
|
||||||
|
<Button>
|
||||||
|
Tambah
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm p-5">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={products}
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
onRowSelectionChange={setRowSelection}
|
||||||
|
filters={[
|
||||||
|
{
|
||||||
|
columnId: 'is_active',
|
||||||
|
title: 'Status',
|
||||||
|
options: [
|
||||||
|
{ label: 'Aktif', value: 'true' },
|
||||||
|
{ label: 'Tidak Aktif', value: 'false' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
bulkActions={[
|
||||||
|
{
|
||||||
|
label: 'Hapus Terpilih',
|
||||||
|
onClick: (rows) => {
|
||||||
|
setRowsToDelete(rows);
|
||||||
|
setIsBulkDeleteDialogOpen(true);
|
||||||
|
},
|
||||||
|
icon: Trash2,
|
||||||
|
variant: 'destructive'
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent size="default">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
|
||||||
|
<Trash2 className="size-5" />
|
||||||
|
</AlertDialogMedia>
|
||||||
|
<AlertDialogTitle>Hapus produk?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Tindakan ini tidak dapat dibatalkan. Produk <strong>{productToDelete?.name}</strong> akan dihapus secara permanen.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel variant="outline">Batal</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmDelete} variant="destructive">Hapus</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent size="default">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
|
||||||
|
<Trash2 className="size-5" />
|
||||||
|
</AlertDialogMedia>
|
||||||
|
<AlertDialogTitle>Hapus {rowsToDelete.length} produk?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Tindakan ini tidak dapat dibatalkan. <strong>{rowsToDelete.length}</strong> item yang terpilih akan dihapus secara permanen.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel variant="outline">Batal</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmBulkDelete}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Hapus
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProductIndex.layout = {
|
||||||
|
breadcrumbs: [
|
||||||
|
{
|
||||||
|
title: 'Master',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@ -2,3 +2,4 @@ export type * from './auth';
|
|||||||
export type * from './navigation';
|
export type * from './navigation';
|
||||||
export type * from './ui';
|
export type * from './ui';
|
||||||
export type * from './category';
|
export type * from './category';
|
||||||
|
export type * from './product';
|
||||||
|
|||||||
23
resources/js/types/product.ts
Normal file
23
resources/js/types/product.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Category } from "./category";
|
||||||
|
|
||||||
|
export interface ProductPrice {
|
||||||
|
id: number;
|
||||||
|
product_id: number;
|
||||||
|
price_type: 'purchase' | 'distributor' | 'agent' | 'reseller' | 'retail';
|
||||||
|
price: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
prices?: ProductPrice[];
|
||||||
|
categories?: Category[];
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||||
|
use App\Http\Controllers\Admin\Master\ProductController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::middleware(['auth'])->group(function () {
|
Route::middleware(['auth'])->group(function () {
|
||||||
@ -11,5 +12,14 @@
|
|||||||
Route::delete('category/destroy/{category}', [CategoryController::class, 'destroy'])->name('category.destroy');
|
Route::delete('category/destroy/{category}', [CategoryController::class, 'destroy'])->name('category.destroy');
|
||||||
Route::delete('category/bulk-destroy', [CategoryController::class, 'bulkDestroy'])->name('category.bulkDestroy');
|
Route::delete('category/bulk-destroy', [CategoryController::class, 'bulkDestroy'])->name('category.bulkDestroy');
|
||||||
Route::patch('category/toggle-status/{category}', [CategoryController::class, 'toggleStatus'])->name('category.toggleStatus');
|
Route::patch('category/toggle-status/{category}', [CategoryController::class, 'toggleStatus'])->name('category.toggleStatus');
|
||||||
|
|
||||||
|
Route::get('products', [ProductController::class, 'index'])->name('product.index');
|
||||||
|
Route::get('product/create', [ProductController::class, 'create'])->name('product.create');
|
||||||
|
Route::post('product/store', [ProductController::class, 'store'])->name('product.store');
|
||||||
|
Route::get('product/{product}/edit', [ProductController::class, 'edit'])->name('product.edit');
|
||||||
|
Route::patch('product/update/{product}', [ProductController::class, 'update'])->name('product.update');
|
||||||
|
Route::delete('product/destroy/{product}', [ProductController::class, 'destroy'])->name('product.destroy');
|
||||||
|
Route::delete('product/bulk-destroy', [ProductController::class, 'bulkDestroy'])->name('product.bulkDestroy');
|
||||||
|
Route::patch('product/toggle-status/{product}', [ProductController::class, 'toggleStatus'])->name('product.toggleStatus');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user