feat: enhance product management with media handling, including thumbnail and image uploads in create/edit forms

This commit is contained in:
Yoga Pangestu 2026-04-17 11:06:32 +07:00
parent 506385939e
commit 0c9d58b551
7 changed files with 523 additions and 120 deletions

View File

@ -12,13 +12,14 @@
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class ProductController extends Controller class ProductController extends Controller
{ {
public function index(): Response public function index(): Response
{ {
return Inertia::render('admin/master/product/index', [ 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(), 'categories' => Category::active()->get(),
]); ]);
} }
@ -34,7 +35,7 @@ public function store(ProductRequest $request): RedirectResponse
{ {
$validated = $request->validated(); $validated = $request->validated();
DB::transaction(function () use ($validated) { DB::transaction(function () use ($request, $validated) {
$product = Product::create([ $product = Product::create([
'name' => $validated['name'], 'name' => $validated['name'],
'description' => $validated['description'], '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'); return redirect()->route('product.index')->with('success', 'Data berhasil disimpan');
@ -61,7 +74,7 @@ public function edit(Product $product): Response
$product->load(['prices', 'categories']); $product->load(['prices', 'categories']);
return Inertia::render('admin/master/product/edit', [ return Inertia::render('admin/master/product/edit', [
'product' => $product, 'product' => $product->toArray(),
'categories' => Category::active()->get(), 'categories' => Category::active()->get(),
]); ]);
} }
@ -70,7 +83,7 @@ public function update(ProductRequest $request, Product $product): RedirectRespo
{ {
$validated = $request->validated(); $validated = $request->validated();
DB::transaction(function () use ($product, $validated) { DB::transaction(function () use ($request, $product, $validated) {
$product->update([ $product->update([
'name' => $validated['name'], 'name' => $validated['name'],
'description' => $validated['description'], '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'); return redirect()->route('product.index')->with('success', 'Data berhasil diperbarui');

View File

@ -35,6 +35,11 @@ public function rules(): array
'prices.agent' => ['required', 'integer', 'min:0'], 'prices.agent' => ['required', 'integer', 'min:0'],
'prices.reseller' => ['required', 'integer', 'min:0'], 'prices.reseller' => ['required', 'integer', 'min:0'],
'prices.retail' => ['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'],
]; ];
} }
} }

View File

@ -4,20 +4,28 @@
use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\Sluggable\HasSlug; use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions; 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 $guarded = ['id'];
protected $appends = [
'thumbnail_url',
'images_data',
];
protected function casts(): array protected function casts(): array
{ {
return [ return [
@ -44,6 +52,31 @@ public function getSlugOptions(): SlugOptions
->saveSlugsTo('slug'); ->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 public function prices(): HasMany
{ {
return $this->hasMany(ProductPrice::class); return $this->hasMany(ProductPrice::class);

View File

@ -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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; 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 { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import productRoutes from '@/routes/product'; import productRoutes from '@/routes/product';
import React from 'react'; import React, { useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor' import { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor'
import { Category } from '@/types/category'; import { Category } from '@/types/category';
@ -22,9 +23,23 @@ import {
useComboboxAnchor, useComboboxAnchor,
} from "@/components/ui/combobox" } from "@/components/ui/combobox"
import { NumericFormat } from 'react-number-format'; import { NumericFormat } from 'react-number-format';
import { ImagePlus, X, Upload } from 'lucide-react';
export default function ProductCreate({ categories }: { categories: Category[] }) { 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: '', name: '',
description: '', description: '',
category_ids: [] as number[], category_ids: [] as number[],
@ -34,21 +49,64 @@ export default function ProductCreate({ categories }: { categories: Category[] }
agent: '', agent: '',
reseller: '', reseller: '',
retail: '', retail: '',
} },
thumbnail: null,
images: [],
}); });
const anchor = useComboboxAnchor() const anchor = useComboboxAnchor();
const thumbnailInputRef = useRef<HTMLInputElement>(null);
const imagesInputRef = useRef<HTMLInputElement>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
const [imagePreviews, setImagePreviews] = useState<{ file: File; preview: string }[]>([]);
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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) => { const onSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
post(productRoutes.store().url, { post(productRoutes.store().url, {
forceFormData: true,
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); 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 ( return (
<div className="flex flex-col gap-6 p-6"> <div className="flex flex-col gap-6 p-6">
@ -86,6 +144,57 @@ export default function ProductCreate({ categories }: { categories: Category[] }
/> />
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>} {errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</Field> </Field>
<Field>
<Label htmlFor="category_ids">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.name}
</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.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
{errors.category_ids && <p className="text-xs text-red-500 mt-1">{errors.category_ids}</p>}
</Field>
<Field>
<Label htmlFor="description">Deskripsi</Label>
<SimpleEditor
value={data.description}
onChange={(val) => setData('description', val)}
/>
{errors.description && <p className="text-xs text-red-500 mt-1">{errors.description}</p>}
</Field>
</CardContent> </CardContent>
</Card> </Card>
@ -202,64 +311,87 @@ export default function ProductCreate({ categories }: { categories: Category[] }
<div className="space-y-6"> <div className="space-y-6">
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm"> <Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
<CardHeader className="border-b"> <CardHeader className="border-b">
<CardTitle>Kategori</CardTitle> <CardTitle>Thumbnail</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Field> {thumbnailPreview ? (
<Combobox <div className="relative inline-block">
multiple <img
autoHighlight src={thumbnailPreview}
items={filteredCategories} alt="Thumbnail preview"
value={categories.filter(c => data.category_ids.includes(c.id))} className="object-cover rounded-xl border shadow"
onValueChange={(selected) => { />
setData( <button
"category_ids", type="button"
selected.map((item: Category) => item.id) onClick={removeThumbnail}
) className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center shadow-md transition-colors"
}} >
<X className="w-3.5 h-3.5" />
</button>
</div>
) : (
<button
type="button"
onClick={() => thumbnailInputRef.current?.click()}
className="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed border-border rounded-xl hover:border-primary hover:bg-primary/5 transition-all cursor-pointer group"
> >
<ComboboxChips ref={anchor} className="w-full"> <ImagePlus className="w-10 h-10 text-muted-foreground group-hover:text-primary transition-colors mb-2" />
<ComboboxValue> <span className="text-sm text-muted-foreground group-hover:text-primary transition-colors font-medium">Klik untuk upload thumbnail</span>
{(values: Category[]) => ( <span className="text-xs text-muted-foreground mt-1">PNG, JPG, WEBP maks. 5MB</span>
<> </button>
{values.map((value) => ( )}
<ComboboxChip key={value.id}> <input
{value.name} ref={thumbnailInputRef}
</ComboboxChip> type="file"
))} accept="image/*"
<ComboboxChipsInput placeholder='Pilih Kategori' /> className="hidden"
</> onChange={handleThumbnailChange}
)} />
</ComboboxValue> {errors.thumbnail && <p className="text-xs text-red-500 mt-2">{errors.thumbnail}</p>}
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>Data tidak ditemukan.</ComboboxEmpty>
<ComboboxList>
{(item: Category) => (
<ComboboxItem key={item.id} value={item}>
{item.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
{errors.category_ids && <p className="text-xs text-red-500 mt-1">{errors.category_ids}</p>}
</Field>
</CardContent> </CardContent>
</Card> </Card>
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm"> <Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
<CardHeader className="border-b"> <CardHeader className="border-b">
<CardTitle>Deskripsi</CardTitle> <CardTitle>Galeri Gambar</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Field> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
<SimpleEditor {imagePreviews.map((item, index) => (
value={data.description} <div key={index} className="relative group aspect-square">
onChange={(val) => setData('description', val)} <img
/> src={item.preview}
{errors.description && <p className="text-xs text-red-500 mt-1">{errors.description}</p>} alt={`Gambar ${index + 1}`}
</Field> className="object-cover rounded-lg border shadow-sm"
/>
<button
type="button"
onClick={() => removeImage(index)}
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center shadow-md transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
<button
type="button"
onClick={() => imagesInputRef.current?.click()}
className="aspect-square flex flex-col items-center justify-center border-2 border-dashed border-border rounded-lg hover:border-primary hover:bg-primary/5 transition-all cursor-pointer group"
>
<Upload className="w-6 h-6 text-muted-foreground group-hover:text-primary transition-colors mb-1" />
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors">Tambah</span>
</button>
</div>
<input
ref={imagesInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleImagesChange}
/>
{errors['images'] && <p className="text-xs text-red-500">{errors['images']}</p>}
</CardContent> </CardContent>
</Card> </Card>

View File

@ -3,12 +3,12 @@ import type { Product, ProductPrice } from '@/types';
import { Category } from '@/types/category'; import { Category } from '@/types/category';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; 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 { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { toast } from 'sonner'; import { toast } from 'sonner';
import productRoutes from '@/routes/product'; 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 { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor';
import { import {
Combobox, Combobox,
@ -23,6 +23,21 @@ import {
useComboboxAnchor, useComboboxAnchor,
} from "@/components/ui/combobox" } from "@/components/ui/combobox"
import { NumericFormat } from 'react-number-format'; 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[] }) { export default function ProductEdit({ product, categories }: { product: Product, categories: Category[] }) {
const getPrice = (type: string) => { 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 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 || '', name: product.name || '',
description: product.description || '', description: product.description || '',
category_ids: initialCategoryIds, category_ids: initialCategoryIds,
@ -42,21 +78,83 @@ export default function ProductEdit({ product, categories }: { product: Product,
agent: getPrice('agent'), agent: getPrice('agent'),
reseller: getPrice('reseller'), reseller: getPrice('reseller'),
retail: getPrice('retail'), retail: getPrice('retail'),
} },
thumbnail: null,
images: [],
delete_image_ids: [],
_method: 'PATCH',
}); });
const anchor = useComboboxAnchor() const anchor = useComboboxAnchor();
const thumbnailInputRef = useRef<HTMLInputElement>(null);
const imagesInputRef = useRef<HTMLInputElement>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(product.thumbnail_url ?? null);
const [thumbnailCleared, setThumbnailCleared] = useState(false);
const [gallery, setGallery] = useState<GalleryItem[]>(initialGallery);
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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) => { const onSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
patch(productRoutes.update(product.id).url, { post(productRoutes.update(product.id).url, {
forceFormData: true,
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); 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 ( return (
<div className="flex flex-col gap-6 p-6"> <div className="flex flex-col gap-6 p-6">
@ -94,6 +192,57 @@ export default function ProductEdit({ product, categories }: { product: Product,
/> />
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>} {errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</Field> </Field>
<Field>
<Label htmlFor="category_ids">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.name}
</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.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
{errors.category_ids && <p className="text-xs text-red-500 mt-1">{errors.category_ids}</p>}
</Field>
<Field>
<Label htmlFor="description">Deskripsi</Label>
<SimpleEditor
value={data.description}
onChange={(val) => setData('description', val)}
/>
{errors.description && <p className="text-xs text-red-500 mt-1">{errors.description}</p>}
</Field>
</CardContent> </CardContent>
</Card> </Card>
@ -207,68 +356,90 @@ export default function ProductEdit({ product, categories }: { product: Product,
</Card> </Card>
</div> </div>
{/* Right Column - 1/3 width */}
<div className="space-y-6"> <div className="space-y-6">
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm"> <Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
<CardHeader className="border-b"> <CardHeader className="border-b">
<CardTitle>Kategori</CardTitle> <CardTitle>Thumbnail</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Field> {thumbnailPreview ? (
<Combobox <div className="relative inline-block">
multiple <img
autoHighlight src={thumbnailPreview}
items={filteredCategories} alt="Thumbnail preview"
value={categories.filter(c => data.category_ids.includes(c.id))} className="object-cover rounded-xl border shadow"
onValueChange={(selected) => { />
setData( <button
"category_ids", type="button"
selected.map((item: Category) => item.id) onClick={removeThumbnail}
) className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center shadow-md transition-colors"
}} >
<X className="w-3.5 h-3.5" />
</button>
</div>
) : (
<button
type="button"
onClick={() => thumbnailInputRef.current?.click()}
className="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed border-border rounded-xl hover:border-primary hover:bg-primary/5 transition-all cursor-pointer group"
> >
<ComboboxChips ref={anchor} className="w-full"> <ImagePlus className="w-10 h-10 text-muted-foreground group-hover:text-primary transition-colors mb-2" />
<ComboboxValue> <span className="text-sm text-muted-foreground group-hover:text-primary transition-colors font-medium">Klik untuk upload thumbnail</span>
{(values: Category[]) => ( <span className="text-xs text-muted-foreground mt-1">PNG, JPG, WEBP maks. 5MB</span>
<> </button>
{values.map((value) => ( )}
<ComboboxChip key={value.id}> <input
{value.name} ref={thumbnailInputRef}
</ComboboxChip> type="file"
))} accept="image/*"
<ComboboxChipsInput placeholder='Pilih Kategori' /> className="hidden"
</> onChange={handleThumbnailChange}
)} />
</ComboboxValue> {errors.thumbnail && <p className="text-xs text-red-500 mt-2">{errors.thumbnail}</p>}
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>Data tidak ditemukan.</ComboboxEmpty>
<ComboboxList>
{(item: Category) => (
<ComboboxItem key={item.id} value={item}>
{item.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
{errors.category_ids && <p className="text-xs text-red-500 mt-1">{errors.category_ids}</p>}
</Field>
</CardContent> </CardContent>
</Card> </Card>
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm"> <Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
<CardHeader className="border-b"> <CardHeader className="border-b">
<CardTitle>Deskripsi</CardTitle> <CardTitle>Galeri Gambar</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Field> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
<SimpleEditor {gallery.map((item, index) => (
value={data.description} <div key={index} className="relative group aspect-square">
onChange={(val) => setData('description', val)} <img
/> src={item.kind === 'existing' ? item.url : item.preview}
{errors.description && <p className="text-xs text-red-500 mt-1">{errors.description}</p>} alt={`Gambar ${index + 1}`}
</Field> className="object-cover rounded-lg border shadow-sm"
/>
<button
type="button"
onClick={() => removeGalleryItem(index)}
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center shadow-md transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
<button
type="button"
onClick={() => imagesInputRef.current?.click()}
className="aspect-square flex flex-col items-center justify-center border-2 border-dashed border-border rounded-lg hover:border-primary hover:bg-primary/5 transition-all cursor-pointer group"
>
<Upload className="w-6 h-6 text-muted-foreground group-hover:text-primary transition-colors mb-1" />
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors">Tambah</span>
</button>
</div>
<input
ref={imagesInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleImagesChange}
/>
{errors['images'] && <p className="text-xs text-red-500">{errors['images']}</p>}
</CardContent> </CardContent>
</Card> </Card>

View File

@ -71,6 +71,29 @@ export default function ProductIndex({ products, categories }: { products: Produ
}; };
const columns: ColumnDef<Product>[] = [ const columns: ColumnDef<Product>[] = [
{
accessorKey: "thumbnail_url",
header: "Thumbnail",
meta: { title: "Thumbnail" },
cell: ({ row }) => {
const product = row.original;
return (
<div className="flex items-center">
{product.thumbnail_url ? (
<img
src={product.thumbnail_url}
alt={product.name}
className="h-12 w-12 rounded-lg object-cover border border-border/50 shadow-sm"
/>
) : (
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center border border-border/50">
<span className="text-[10px] text-muted-foreground uppercase font-semibold">N/A</span>
</div>
)}
</div>
);
}
},
{ {
accessorKey: "name", accessorKey: "name",
header: ({ column }) => { header: ({ column }) => {
@ -98,7 +121,7 @@ export default function ProductIndex({ products, categories }: { products: Produ
return ( return (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{product.categories?.map((category) => ( {product.categories?.map((category) => (
<Badge key={category.id} variant="secondary"> <Badge key={category.id} variant="secondary" className='font-medium'>
{category.name} {category.name}
</Badge> </Badge>
))} ))}
@ -133,7 +156,7 @@ export default function ProductIndex({ products, categories }: { products: Produ
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Link href={productRoutes.edit(product.id).url}> <Link href={productRoutes.edit(product.id).url}>
<Button variant="ghost" size="icon" className='text-yellow-600'> <Button variant="ghost" size="icon" className='text-yellow-600 hover:text-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-950/20'>
<Pencil className="size-4" /> <Pencil className="size-4" />
</Button> </Button>
</Link> </Link>
@ -144,7 +167,7 @@ export default function ProductIndex({ products, categories }: { products: Produ
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" className='text-red-600' onClick={() => onDelete(product)}> <Button variant="ghost" size="icon" className='text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20' onClick={() => onDelete(product)}>
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>

View File

@ -9,6 +9,11 @@ export interface ProductPrice {
updated_at: string; updated_at: string;
} }
export interface ProductImage {
id: number;
url: string;
}
export interface Product { export interface Product {
id: number; id: number;
name: string; name: string;
@ -20,4 +25,6 @@ export interface Product {
deleted_at: string | null; deleted_at: string | null;
prices?: ProductPrice[]; prices?: ProductPrice[];
categories?: Category[]; categories?: Category[];
thumbnail_url?: string | null;
images_data?: ProductImage[];
} }