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 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');
|
||||||
|
|||||||
@ -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'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user