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;
|
||||
|
||||
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\SoftDeletes;
|
||||
use Spatie\Sluggable\HasSlug;
|
||||
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
|
||||
{
|
||||
return SlugOptions::create()
|
||||
->generateSlugsFrom('title')
|
||||
->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([
|
||||
UserSeeder::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 { LayoutGrid, List } from 'lucide-react';
|
||||
import { Boxes, LayoutGrid, List } from 'lucide-react';
|
||||
import AppLogo from '@/components/app-logo';
|
||||
import { NavMain } from '@/components/nav-main';
|
||||
import {
|
||||
@ -14,6 +14,7 @@ import { dashboard } from '@/routes';
|
||||
import category from '@/routes/category';
|
||||
|
||||
import type { NavItem } from '@/types';
|
||||
import product from '@/routes/product';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
@ -29,6 +30,11 @@ const masterNavItems: NavItem[] = [
|
||||
href: category.index().url,
|
||||
icon: List,
|
||||
},
|
||||
{
|
||||
title: 'Produk',
|
||||
href: product.index().url,
|
||||
icon: Boxes,
|
||||
},
|
||||
];
|
||||
|
||||
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 './ui';
|
||||
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
|
||||
|
||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||
use App\Http\Controllers\Admin\Master\ProductController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
@ -11,5 +12,14 @@
|
||||
Route::delete('category/destroy/{category}', [CategoryController::class, 'destroy'])->name('category.destroy');
|
||||
Route::delete('category/bulk-destroy', [CategoryController::class, 'bulkDestroy'])->name('category.bulkDestroy');
|
||||
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