feat: enhance product management with media handling, including thumbnail and image uploads in create/edit forms
This commit is contained in:
parent
506385939e
commit
0c9d58b551
@ -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');
|
||||
|
||||
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user