From 0c9d58b551b5484393625061535de193441d0088 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 17 Apr 2026 11:06:32 +0700 Subject: [PATCH] feat: enhance product management with media handling, including thumbnail and image uploads in create/edit forms --- .../Admin/Master/ProductController.php | 40 ++- .../Requests/Admin/Master/ProductRequest.php | 5 + app/Models/Product.php | 37 ++- .../js/pages/admin/master/product/create.tsx | 242 +++++++++++---- .../js/pages/admin/master/product/edit.tsx | 283 ++++++++++++++---- .../js/pages/admin/master/product/index.tsx | 29 +- resources/js/types/product.ts | 7 + 7 files changed, 523 insertions(+), 120 deletions(-) diff --git a/app/Http/Controllers/Admin/Master/ProductController.php b/app/Http/Controllers/Admin/Master/ProductController.php index cfb5128..f077253 100644 --- a/app/Http/Controllers/Admin/Master/ProductController.php +++ b/app/Http/Controllers/Admin/Master/ProductController.php @@ -12,13 +12,14 @@ use Illuminate\Support\Facades\DB; use Inertia\Inertia; use Inertia\Response; +use Spatie\MediaLibrary\MediaCollections\Models\Media; class ProductController extends Controller { public function index(): Response { return Inertia::render('admin/master/product/index', [ - 'products' => Product::with(['prices', 'categories'])->latest()->get(), + 'products' => Product::with(['prices', 'categories', 'media'])->latest()->get(), 'categories' => Category::active()->get(), ]); } @@ -34,7 +35,7 @@ public function store(ProductRequest $request): RedirectResponse { $validated = $request->validated(); - DB::transaction(function () use ($validated) { + DB::transaction(function () use ($request, $validated) { $product = Product::create([ 'name' => $validated['name'], 'description' => $validated['description'], @@ -51,6 +52,18 @@ public function store(ProductRequest $request): RedirectResponse ]); } } + + if ($request->hasFile('thumbnail')) { + $product->addMediaFromRequest('thumbnail') + ->toMediaCollection('thumbnail'); + } + + if ($request->hasFile('images')) { + foreach ($request->file('images') as $image) { + $product->addMedia($image) + ->toMediaCollection('images'); + } + } }); return redirect()->route('product.index')->with('success', 'Data berhasil disimpan'); @@ -61,7 +74,7 @@ public function edit(Product $product): Response $product->load(['prices', 'categories']); return Inertia::render('admin/master/product/edit', [ - 'product' => $product, + 'product' => $product->toArray(), 'categories' => Category::active()->get(), ]); } @@ -70,7 +83,7 @@ public function update(ProductRequest $request, Product $product): RedirectRespo { $validated = $request->validated(); - DB::transaction(function () use ($product, $validated) { + DB::transaction(function () use ($request, $product, $validated) { $product->update([ 'name' => $validated['name'], 'description' => $validated['description'], @@ -87,6 +100,25 @@ public function update(ProductRequest $request, Product $product): RedirectRespo ); } } + + if (! empty($validated['delete_image_ids'])) { + Media::whereIn('id', $validated['delete_image_ids']) + ->where('model_type', Product::class) + ->where('model_id', $product->id) + ->each(fn ($media) => $media->delete()); + } + + if ($request->hasFile('thumbnail')) { + $product->addMediaFromRequest('thumbnail') + ->toMediaCollection('thumbnail'); + } + + if ($request->hasFile('images')) { + foreach ($request->file('images') as $image) { + $product->addMedia($image) + ->toMediaCollection('images'); + } + } }); return redirect()->route('product.index')->with('success', 'Data berhasil diperbarui'); diff --git a/app/Http/Requests/Admin/Master/ProductRequest.php b/app/Http/Requests/Admin/Master/ProductRequest.php index 65dd6b7..04489c6 100644 --- a/app/Http/Requests/Admin/Master/ProductRequest.php +++ b/app/Http/Requests/Admin/Master/ProductRequest.php @@ -35,6 +35,11 @@ public function rules(): array 'prices.agent' => ['required', 'integer', 'min:0'], 'prices.reseller' => ['required', 'integer', 'min:0'], 'prices.retail' => ['required', 'integer', 'min:0'], + 'thumbnail' => ['nullable', 'image', 'max:5120'], + 'images' => ['nullable', 'array'], + 'images.*' => ['image', 'max:5120'], + 'delete_image_ids' => ['nullable', 'array'], + 'delete_image_ids.*' => ['integer'], ]; } } diff --git a/app/Models/Product.php b/app/Models/Product.php index 9053257..e0dbd05 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -4,20 +4,28 @@ use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; 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\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\Sluggable\HasSlug; use Spatie\Sluggable\SlugOptions; -class Product extends Model +class Product extends Model implements HasMedia { - use HasFactory, HasSlug, SoftDeletes; + use HasFactory, HasSlug, InteractsWithMedia, SoftDeletes; protected $guarded = ['id']; + protected $appends = [ + 'thumbnail_url', + 'images_data', + ]; + protected function casts(): array { return [ @@ -44,6 +52,31 @@ public function getSlugOptions(): SlugOptions ->saveSlugsTo('slug'); } + public function registerMediaCollections(): void + { + $this->addMediaCollection('thumbnail') + ->singleFile(); + + $this->addMediaCollection('images'); + } + + protected function thumbnailUrl(): Attribute + { + return Attribute::make( + get: fn () => $this->getFirstMediaUrl('thumbnail') ?: null, + ); + } + + protected function imagesData(): Attribute + { + return Attribute::make( + get: fn () => $this->getMedia('images')->map(fn ($media) => [ + 'id' => $media->id, + 'url' => $media->getUrl(), + ])->toArray(), + ); + } + public function prices(): HasMany { return $this->hasMany(ProductPrice::class); diff --git a/resources/js/pages/admin/master/product/create.tsx b/resources/js/pages/admin/master/product/create.tsx index dd67ca7..d1d797a 100644 --- a/resources/js/pages/admin/master/product/create.tsx +++ b/resources/js/pages/admin/master/product/create.tsx @@ -1,11 +1,12 @@ -import { Head, useForm, Link } from '@inertiajs/react'; +import { Head, Link } from '@inertiajs/react'; +import { useForm } from '@inertiajs/react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Field, FieldGroup } from "@/components/ui/field" +import { Field } 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 React, { useRef, useState } from 'react'; import { toast } from 'sonner'; import { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor' import { Category } from '@/types/category'; @@ -22,9 +23,23 @@ import { useComboboxAnchor, } from "@/components/ui/combobox" import { NumericFormat } from 'react-number-format'; +import { ImagePlus, X, Upload } from 'lucide-react'; export default function ProductCreate({ categories }: { categories: Category[] }) { - const { data, setData, post, processing, errors } = useForm({ + const { data, setData, post, processing, errors } = useForm<{ + name: string; + description: string; + category_ids: number[]; + prices: { + purchase: string; + distributor: string; + agent: string; + reseller: string; + retail: string; + }; + thumbnail: File | null; + images: File[]; + }>({ name: '', description: '', category_ids: [] as number[], @@ -34,21 +49,64 @@ export default function ProductCreate({ categories }: { categories: Category[] } agent: '', reseller: '', retail: '', - } + }, + thumbnail: null, + images: [], }); - const anchor = useComboboxAnchor() + const anchor = useComboboxAnchor(); + const thumbnailInputRef = useRef(null); + const imagesInputRef = useRef(null); + + const [thumbnailPreview, setThumbnailPreview] = useState(null); + const [imagePreviews, setImagePreviews] = useState<{ file: File; preview: string }[]>([]); + + const handleThumbnailChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setData('thumbnail', file); + const reader = new FileReader(); + reader.onloadend = () => setThumbnailPreview(reader.result as string); + reader.readAsDataURL(file); + }; + + const removeThumbnail = () => { + setData('thumbnail', null); + setThumbnailPreview(null); + if (thumbnailInputRef.current) thumbnailInputRef.current.value = ''; + }; + + const handleImagesChange = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files ?? []); + if (!files.length) return; + + const newPreviews = files.map(file => ({ + file, + preview: URL.createObjectURL(file), + })); + + setImagePreviews(prev => [...prev, ...newPreviews]); + setData('images', [...data.images, ...files]); + if (imagesInputRef.current) imagesInputRef.current.value = ''; + }; + + const removeImage = (index: number) => { + const updated = imagePreviews.filter((_, i) => i !== index); + setImagePreviews(updated); + setData('images', updated.map(p => p.file)); + }; const onSubmit = (e: React.FormEvent) => { e.preventDefault(); post(productRoutes.store().url, { + forceFormData: true, onSuccess: (response: any) => { toast.success(response.props.flash.success); }, }); }; - const filteredCategories = categories.filter((c) => !data.category_ids.includes(c.id)) + const filteredCategories = categories.filter((c) => !data.category_ids.includes(c.id)); return (
@@ -86,6 +144,57 @@ export default function ProductCreate({ categories }: { categories: Category[] } /> {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.name} + + ))} + + + )} + + + + Data tidak ditemukan. + + {(item: Category) => ( + + {item.name} + + )} + + + + {errors.category_ids &&

{errors.category_ids}

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

{errors.description}

} +
@@ -202,64 +311,87 @@ export default function ProductCreate({ categories }: { categories: Category[] }
- Kategori + Thumbnail - - data.category_ids.includes(c.id))} - onValueChange={(selected) => { - setData( - "category_ids", - selected.map((item: Category) => item.id) - ) - }} + {thumbnailPreview ? ( +
+ Thumbnail preview + +
+ ) : ( + + )} + + {errors.thumbnail &&

{errors.thumbnail}

}
- Deskripsi + Galeri Gambar - - setData('description', val)} - /> - {errors.description &&

{errors.description}

} -
+
+ {imagePreviews.map((item, index) => ( +
+ {`Gambar + +
+ ))} + + +
+ + {errors['images'] &&

{errors['images']}

}
diff --git a/resources/js/pages/admin/master/product/edit.tsx b/resources/js/pages/admin/master/product/edit.tsx index 6e7dc97..5a8c521 100644 --- a/resources/js/pages/admin/master/product/edit.tsx +++ b/resources/js/pages/admin/master/product/edit.tsx @@ -3,12 +3,12 @@ import type { Product, ProductPrice } from '@/types'; import { Category } from '@/types/category'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Field, FieldGroup } from "@/components/ui/field" +import { Field } 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 React, { useRef, useState } from 'react'; import { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor'; import { Combobox, @@ -23,6 +23,21 @@ import { useComboboxAnchor, } from "@/components/ui/combobox" import { NumericFormat } from 'react-number-format'; +import { ImagePlus, X, Upload } from 'lucide-react'; + +interface ExistingImage { + kind: 'existing'; + id: number; + url: string; +} + +interface NewImage { + kind: 'new'; + file: File; + preview: string; +} + +type GalleryItem = ExistingImage | NewImage; export default function ProductEdit({ product, categories }: { product: Product, categories: Category[] }) { const getPrice = (type: string) => { @@ -32,7 +47,28 @@ export default function ProductEdit({ product, categories }: { product: Product, const initialCategoryIds = product.categories?.map(c => c.id) || []; - const { data, setData, patch, processing, errors } = useForm({ + const initialGallery: GalleryItem[] = (product.images_data ?? []).map(img => ({ + kind: 'existing', + id: img.id, + url: img.url, + })); + + const { data, setData, post, processing, errors } = useForm<{ + name: string; + description: string; + category_ids: number[]; + prices: { + purchase: string; + distributor: string; + agent: string; + reseller: string; + retail: string; + }; + thumbnail: File | null; + images: File[]; + delete_image_ids: number[]; + _method: string; + }>({ name: product.name || '', description: product.description || '', category_ids: initialCategoryIds, @@ -42,21 +78,83 @@ export default function ProductEdit({ product, categories }: { product: Product, agent: getPrice('agent'), reseller: getPrice('reseller'), retail: getPrice('retail'), - } + }, + thumbnail: null, + images: [], + delete_image_ids: [], + _method: 'PATCH', }); - const anchor = useComboboxAnchor() + const anchor = useComboboxAnchor(); + const thumbnailInputRef = useRef(null); + const imagesInputRef = useRef(null); + + const [thumbnailPreview, setThumbnailPreview] = useState(product.thumbnail_url ?? null); + const [thumbnailCleared, setThumbnailCleared] = useState(false); + + const [gallery, setGallery] = useState(initialGallery); + + const handleThumbnailChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setData('thumbnail', file); + setThumbnailCleared(false); + const reader = new FileReader(); + reader.onloadend = () => setThumbnailPreview(reader.result as string); + reader.readAsDataURL(file); + }; + + const removeThumbnail = () => { + setData('thumbnail', null); + setThumbnailPreview(null); + setThumbnailCleared(true); + if (thumbnailInputRef.current) thumbnailInputRef.current.value = ''; + }; + + const handleImagesChange = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files ?? []); + if (!files.length) return; + + const newItems: NewImage[] = files.map(file => ({ + kind: 'new', + file, + preview: URL.createObjectURL(file), + })); + + setGallery(prev => [...prev, ...newItems]); + setData('images', [...data.images, ...files]); + if (imagesInputRef.current) imagesInputRef.current.value = ''; + }; + + const removeGalleryItem = (index: number) => { + const item = gallery[index]; + const updatedGallery = gallery.filter((_, i) => i !== index); + setGallery(updatedGallery); + + if (item.kind === 'existing') { + setData(prev => ({ + ...prev, + delete_image_ids: [...prev.delete_image_ids, item.id], + })); + } else { + const newFiles = updatedGallery + .filter((g): g is NewImage => g.kind === 'new') + .map(g => g.file); + setData('images', newFiles); + } + }; const onSubmit = (e: React.FormEvent) => { e.preventDefault(); - patch(productRoutes.update(product.id).url, { + post(productRoutes.update(product.id).url, { + forceFormData: true, onSuccess: (response: any) => { toast.success(response.props.flash.success); }, }); }; - const filteredCategories = categories.filter((c) => !data.category_ids.includes(c.id)) + const filteredCategories = categories.filter((c) => !data.category_ids.includes(c.id)); return (
@@ -94,6 +192,57 @@ export default function ProductEdit({ product, categories }: { product: Product, /> {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.name} + + ))} + + + )} + + + + Data tidak ditemukan. + + {(item: Category) => ( + + {item.name} + + )} + + + + {errors.category_ids &&

{errors.category_ids}

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

{errors.description}

} +
@@ -207,68 +356,90 @@ export default function ProductEdit({ product, categories }: { product: Product,
- {/* Right Column - 1/3 width */}
- Kategori + Thumbnail - - data.category_ids.includes(c.id))} - onValueChange={(selected) => { - setData( - "category_ids", - selected.map((item: Category) => item.id) - ) - }} + {thumbnailPreview ? ( +
+ Thumbnail preview + +
+ ) : ( + + )} + + {errors.thumbnail &&

{errors.thumbnail}

}
- Deskripsi + Galeri Gambar - - setData('description', val)} - /> - {errors.description &&

{errors.description}

} -
+
+ {gallery.map((item, index) => ( +
+ {`Gambar + +
+ ))} + + +
+ + {errors['images'] &&

{errors['images']}

}
diff --git a/resources/js/pages/admin/master/product/index.tsx b/resources/js/pages/admin/master/product/index.tsx index 676831a..e1074cd 100644 --- a/resources/js/pages/admin/master/product/index.tsx +++ b/resources/js/pages/admin/master/product/index.tsx @@ -71,6 +71,29 @@ export default function ProductIndex({ products, categories }: { products: Produ }; const columns: ColumnDef[] = [ + { + accessorKey: "thumbnail_url", + header: "Thumbnail", + meta: { title: "Thumbnail" }, + cell: ({ row }) => { + const product = row.original; + return ( +
+ {product.thumbnail_url ? ( + {product.name} + ) : ( +
+ N/A +
+ )} +
+ ); + } + }, { accessorKey: "name", header: ({ column }) => { @@ -98,7 +121,7 @@ export default function ProductIndex({ products, categories }: { products: Produ return (
{product.categories?.map((category) => ( - + {category.name} ))} @@ -133,7 +156,7 @@ export default function ProductIndex({ products, categories }: { products: Produ - @@ -144,7 +167,7 @@ export default function ProductIndex({ products, categories }: { products: Produ - diff --git a/resources/js/types/product.ts b/resources/js/types/product.ts index b83bd00..e84d8a7 100644 --- a/resources/js/types/product.ts +++ b/resources/js/types/product.ts @@ -9,6 +9,11 @@ export interface ProductPrice { updated_at: string; } +export interface ProductImage { + id: number; + url: string; +} + export interface Product { id: number; name: string; @@ -20,4 +25,6 @@ export interface Product { deleted_at: string | null; prices?: ProductPrice[]; categories?: Category[]; + thumbnail_url?: string | null; + images_data?: ProductImage[]; }