feat: implement product management module with CRUD operations, validation, and UI integration

This commit is contained in:
Yoga Pangestu 2026-04-16 13:03:38 +07:00
parent bcd08e28ed
commit 4b5d5af316
19 changed files with 1271 additions and 1 deletions

23
app/Enums/PriceType.php Normal file
View 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',
};
}
}

View 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');
}
}

View 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'],
];
}
}

View File

@ -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
View 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);
}
}

View 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);
}
}

View 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],
]);
});
}
}

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -16,6 +16,7 @@ public function run(): void
$this->call([
UserSeeder::class,
CategorySeeder::class,
ProductSeeder::class,
]);
}
}

View 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,
]);
}
}
}
}

View File

@ -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() {

View 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',
},
],
};

View 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',
},
],
};

View 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',
},
],
};

View File

@ -2,3 +2,4 @@ export type * from './auth';
export type * from './navigation';
export type * from './ui';
export type * from './category';
export type * from './product';

View 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[];
}

View File

@ -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');
});
});