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

View File

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

View File

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

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 { 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<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) => {
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 (
<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>}
</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>
</Card>
@ -202,64 +311,87 @@ export default function ProductCreate({ categories }: { categories: Category[] }
<div className="space-y-6">
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
<CardHeader className="border-b">
<CardTitle>Kategori</CardTitle>
<CardTitle>Thumbnail</CardTitle>
</CardHeader>
<CardContent>
<Field>
<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)
)
}}
{thumbnailPreview ? (
<div className="relative inline-block">
<img
src={thumbnailPreview}
alt="Thumbnail preview"
className="object-cover rounded-xl border shadow"
/>
<button
type="button"
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">
<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>
<ImagePlus className="w-10 h-10 text-muted-foreground group-hover:text-primary transition-colors mb-2" />
<span className="text-sm text-muted-foreground group-hover:text-primary transition-colors font-medium">Klik untuk upload thumbnail</span>
<span className="text-xs text-muted-foreground mt-1">PNG, JPG, WEBP maks. 5MB</span>
</button>
)}
<input
ref={thumbnailInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleThumbnailChange}
/>
{errors.thumbnail && <p className="text-xs text-red-500 mt-2">{errors.thumbnail}</p>}
</CardContent>
</Card>
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
<CardHeader className="border-b">
<CardTitle>Deskripsi</CardTitle>
<CardTitle>Galeri Gambar</CardTitle>
</CardHeader>
<CardContent>
<Field>
<SimpleEditor
value={data.description}
onChange={(val) => setData('description', val)}
/>
{errors.description && <p className="text-xs text-red-500 mt-1">{errors.description}</p>}
</Field>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{imagePreviews.map((item, index) => (
<div key={index} className="relative group aspect-square">
<img
src={item.preview}
alt={`Gambar ${index + 1}`}
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>
</Card>

View File

@ -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<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) => {
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 (
<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>}
</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>
</Card>
@ -207,68 +356,90 @@ export default function ProductEdit({ product, categories }: { product: Product,
</Card>
</div>
{/* Right Column - 1/3 width */}
<div className="space-y-6">
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
<CardHeader className="border-b">
<CardTitle>Kategori</CardTitle>
<CardTitle>Thumbnail</CardTitle>
</CardHeader>
<CardContent>
<Field>
<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)
)
}}
{thumbnailPreview ? (
<div className="relative inline-block">
<img
src={thumbnailPreview}
alt="Thumbnail preview"
className="object-cover rounded-xl border shadow"
/>
<button
type="button"
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">
<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>
<ImagePlus className="w-10 h-10 text-muted-foreground group-hover:text-primary transition-colors mb-2" />
<span className="text-sm text-muted-foreground group-hover:text-primary transition-colors font-medium">Klik untuk upload thumbnail</span>
<span className="text-xs text-muted-foreground mt-1">PNG, JPG, WEBP maks. 5MB</span>
</button>
)}
<input
ref={thumbnailInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleThumbnailChange}
/>
{errors.thumbnail && <p className="text-xs text-red-500 mt-2">{errors.thumbnail}</p>}
</CardContent>
</Card>
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
<CardHeader className="border-b">
<CardTitle>Deskripsi</CardTitle>
<CardTitle>Galeri Gambar</CardTitle>
</CardHeader>
<CardContent>
<Field>
<SimpleEditor
value={data.description}
onChange={(val) => setData('description', val)}
/>
{errors.description && <p className="text-xs text-red-500 mt-1">{errors.description}</p>}
</Field>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{gallery.map((item, index) => (
<div key={index} className="relative group aspect-square">
<img
src={item.kind === 'existing' ? item.url : item.preview}
alt={`Gambar ${index + 1}`}
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>
</Card>

View File

@ -71,6 +71,29 @@ export default function ProductIndex({ products, categories }: { products: Produ
};
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",
header: ({ column }) => {
@ -98,7 +121,7 @@ export default function ProductIndex({ products, categories }: { products: Produ
return (
<div className="flex flex-wrap gap-1">
{product.categories?.map((category) => (
<Badge key={category.id} variant="secondary">
<Badge key={category.id} variant="secondary" className='font-medium'>
{category.name}
</Badge>
))}
@ -133,7 +156,7 @@ export default function ProductIndex({ products, categories }: { products: Produ
<Tooltip>
<TooltipTrigger asChild>
<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" />
</Button>
</Link>
@ -144,7 +167,7 @@ export default function ProductIndex({ products, categories }: { products: Produ
</Tooltip>
<Tooltip>
<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" />
</Button>
</TooltipTrigger>

View File

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