diff --git a/app/Enums/PriceType.php b/app/Enums/PriceType.php new file mode 100644 index 0000000..409fcbd --- /dev/null +++ b/app/Enums/PriceType.php @@ -0,0 +1,23 @@ + 'Beli', + self::DISTRIBUTOR => 'Distributor', + self::AGENT => 'Agen', + self::RESELLER => 'Reseller', + self::RETAIL => 'Retail', + }; + } +} diff --git a/app/Http/Controllers/Admin/Master/ProductController.php b/app/Http/Controllers/Admin/Master/ProductController.php new file mode 100644 index 0000000..0b478f1 --- /dev/null +++ b/app/Http/Controllers/Admin/Master/ProductController.php @@ -0,0 +1,118 @@ + 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'); + } +} diff --git a/app/Http/Requests/Admin/Master/ProductRequest.php b/app/Http/Requests/Admin/Master/ProductRequest.php new file mode 100644 index 0000000..65dd6b7 --- /dev/null +++ b/app/Http/Requests/Admin/Master/ProductRequest.php @@ -0,0 +1,40 @@ +|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'], + ]; + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php index e1a0492..3de706d 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -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); + } } diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 0000000..9053257 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,56 @@ + '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); + } +} diff --git a/app/Models/ProductPrice.php b/app/Models/ProductPrice.php new file mode 100644 index 0000000..1ab6f00 --- /dev/null +++ b/app/Models/ProductPrice.php @@ -0,0 +1,25 @@ + PriceType::class, + 'price' => 'integer', + ]; + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 0000000..b898fcf --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,49 @@ + + */ +class ProductFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + 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], + ]); + }); + } +} diff --git a/database/migrations/2026_04_16_082802_create_products_table.php b/database/migrations/2026_04_16_082802_create_products_table.php new file mode 100644 index 0000000..b93fd9e --- /dev/null +++ b/database/migrations/2026_04_16_082802_create_products_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/database/migrations/2026_04_16_084144_create_product_prices_table.php b/database/migrations/2026_04_16_084144_create_product_prices_table.php new file mode 100644 index 0000000..004c842 --- /dev/null +++ b/database/migrations/2026_04_16_084144_create_product_prices_table.php @@ -0,0 +1,32 @@ +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'); + } +}; diff --git a/database/migrations/2026_04_16_112851_create_category_product_table.php b/database/migrations/2026_04_16_112851_create_category_product_table.php new file mode 100644 index 0000000..7160f7b --- /dev/null +++ b/database/migrations/2026_04_16_112851_create_category_product_table.php @@ -0,0 +1,29 @@ +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'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index f198fca..278e9e8 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -16,6 +16,7 @@ public function run(): void $this->call([ UserSeeder::class, CategorySeeder::class, + ProductSeeder::class, ]); } } diff --git a/database/seeders/ProductSeeder.php b/database/seeders/ProductSeeder.php new file mode 100644 index 0000000..db47509 --- /dev/null +++ b/database/seeders/ProductSeeder.php @@ -0,0 +1,100 @@ +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, + ]); + } + } + } +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index d55325f..f6b1e31 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -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() { diff --git a/resources/js/pages/admin/master/product/create.tsx b/resources/js/pages/admin/master/product/create.tsx new file mode 100644 index 0000000..f33e00a --- /dev/null +++ b/resources/js/pages/admin/master/product/create.tsx @@ -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 ( +
+ + +
+
+

Tambah Produk

+
+
+ + + +
+ + + + setData('name', e.target.value)} + autoComplete='off' + placeholder='Contoh: Gamis Wanita' + maxLength={100} + /> + {errors.name &&

{errors.name}

} +
+ +
+ + + data.category_ids.includes(c.id))} + onValueChange={(selected) => { + setData( + "category_ids", + selected.map((item: Category) => item.id) + ) + }} + > + + + {(values: Category[]) => ( + <> + {values.map((value) => ( + + {value.title} + + ))} + + + )} + + + + Data tidak ditemukan. + + {(item: Category) => ( + + {item.title} + + )} + + + + {errors.category_ids &&

{errors.category_ids}

} +
+ + + + setData('prices', { ...data.prices, purchase: e.target.value })} + placeholder='0' + /> + {errors['prices.purchase'] &&

{errors['prices.purchase']}

} +
+ + + + setData('prices', { ...data.prices, distributor: e.target.value })} + placeholder='0' + /> + {errors['prices.distributor'] &&

{errors['prices.distributor']}

} +
+ + + + setData('prices', { ...data.prices, agent: e.target.value })} + placeholder='0' + /> + {errors['prices.agent'] &&

{errors['prices.agent']}

} +
+ + + + setData('prices', { ...data.prices, reseller: e.target.value })} + placeholder='0' + /> + {errors['prices.reseller'] &&

{errors['prices.reseller']}

} +
+ + + + setData('prices', { ...data.prices, retail: e.target.value })} + placeholder='0' + /> + {errors['prices.retail'] &&

{errors['prices.retail']}

} +
+
+ + + + setData('description', val)} + /> + {errors.description &&

{errors.description}

} +
+
+ +
+ + + + +
+
+
+
+ +
+ ); +} + +ProductCreate.layout = { + breadcrumbs: [ + { + title: 'Master', + }, + ], +}; diff --git a/resources/js/pages/admin/master/product/edit.tsx b/resources/js/pages/admin/master/product/edit.tsx new file mode 100644 index 0000000..4aff1d8 --- /dev/null +++ b/resources/js/pages/admin/master/product/edit.tsx @@ -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 ( +
+ + +
+
+

Ubah Produk

+
+
+ + + +
+ + + + setData('name', e.target.value)} + autoComplete='off' + placeholder='Contoh: Gamis Wanita' + maxLength={100} + /> + {errors.name &&

{errors.name}

} +
+ +
+ + + data.category_ids.includes(c.id))} + onValueChange={(selected) => { + setData( + "category_ids", + selected.map((item: Category) => item.id) + ) + }} + > + + + {(values: Category[]) => ( + <> + {values.map((value) => ( + + {value.title} + + ))} + + + )} + + + + Data tidak ditemukan. + + {(item: Category) => ( + + {item.title} + + )} + + + + {errors.category_ids &&

{errors.category_ids}

} +
+ + + + setData('prices', { ...data.prices, purchase: e.target.value })} + placeholder='0' + /> + {errors['prices.purchase'] &&

{errors['prices.purchase']}

} +
+ + + + setData('prices', { ...data.prices, distributor: e.target.value })} + placeholder='0' + /> + {errors['prices.distributor'] &&

{errors['prices.distributor']}

} +
+ + + + setData('prices', { ...data.prices, agent: e.target.value })} + placeholder='0' + /> + {errors['prices.agent'] &&

{errors['prices.agent']}

} +
+ + + + setData('prices', { ...data.prices, reseller: e.target.value })} + placeholder='0' + /> + {errors['prices.reseller'] &&

{errors['prices.reseller']}

} +
+ + + + setData('prices', { ...data.prices, retail: e.target.value })} + placeholder='0' + /> + {errors['prices.retail'] &&

{errors['prices.retail']}

} +
+
+ + + + setData('description', val)} + /> + {errors.description &&

{errors.description}

} +
+
+ +
+ + + + +
+
+
+
+ +
+ ); +} + +ProductEdit.layout = { + breadcrumbs: [ + { + title: 'Master', + }, + ], +}; diff --git a/resources/js/pages/admin/master/product/index.tsx b/resources/js/pages/admin/master/product/index.tsx new file mode 100644 index 0000000..ea57f45 --- /dev/null +++ b/resources/js/pages/admin/master/product/index.tsx @@ -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(null); + const [rowsToDelete, setRowsToDelete] = useState([]); + 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[] = [ + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ) + }, + meta: { title: "Nama" }, + }, + { + accessorKey: "categories", + header: ({ column }) => { + return ( + + ) + }, + meta: { title: "Kategori" }, + cell: ({ row }) => { + const product = row.original; + return ( +
+ {product.categories?.map((category) => ( + + {category.title} + + ))} + {(!product.categories || product.categories.length === 0) && ( + Tanpa Kategori + )} +
+ ); + } + }, + { + accessorKey: "is_active", + header: "Status", + meta: { title: "Status" }, + cell: ({ row }) => { + const product = row.original; + return ( + onToggleStatus(product.id)} + /> + ); + } + }, + { + id: "actions", + header: "Aksi", + cell: ({ row }) => { + const product = row.original; + return ( +
+ + + + + + + +

Ubah

+
+
+ + + + + +

Hapus

+
+
+
+ ); + }, + meta: { title: "Aksi" }, + }, + ]; + + return ( +
+ + +
+
+

Produk

+
+ + + +
+ + + + { + setRowsToDelete(rows); + setIsBulkDeleteDialogOpen(true); + }, + icon: Trash2, + variant: 'destructive' + }, + ]} + /> + + + + + + + + + + Hapus produk? + + Tindakan ini tidak dapat dibatalkan. Produk {productToDelete?.name} akan dihapus secara permanen. + + + + Batal + Hapus + + + + + + + + + + + Hapus {rowsToDelete.length} produk? + + Tindakan ini tidak dapat dibatalkan. {rowsToDelete.length} item yang terpilih akan dihapus secara permanen. + + + + Batal + + Hapus + + + + +
+ ); +} + +ProductIndex.layout = { + breadcrumbs: [ + { + title: 'Master', + }, + ], +}; diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts index be53eca..7533781 100644 --- a/resources/js/types/index.ts +++ b/resources/js/types/index.ts @@ -2,3 +2,4 @@ export type * from './auth'; export type * from './navigation'; export type * from './ui'; export type * from './category'; +export type * from './product'; diff --git a/resources/js/types/product.ts b/resources/js/types/product.ts new file mode 100644 index 0000000..b83bd00 --- /dev/null +++ b/resources/js/types/product.ts @@ -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[]; +} diff --git a/routes/master.php b/routes/master.php index df57253..c939b62 100644 --- a/routes/master.php +++ b/routes/master.php @@ -1,6 +1,7 @@ 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'); }); });