435 lines
22 KiB
TypeScript
435 lines
22 KiB
TypeScript
import { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import {
|
|
Combobox,
|
|
ComboboxChip,
|
|
ComboboxChips,
|
|
ComboboxChipsInput,
|
|
ComboboxContent,
|
|
ComboboxEmpty,
|
|
ComboboxItem,
|
|
ComboboxList,
|
|
ComboboxValue,
|
|
useComboboxAnchor,
|
|
} from "@/components/ui/combobox";
|
|
import { Field, FieldError } from "@/components/ui/field";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import productRoutes from '@/routes/product';
|
|
import type { Category } from '@/types/category';
|
|
import { Head, Link, useForm } from '@inertiajs/react';
|
|
import { ArrowLeft, ImagePlus, Loader, Save, Upload, X } from 'lucide-react';
|
|
import React, { useRef, useState } from 'react';
|
|
import { NumericFormat } from 'react-number-format';
|
|
import { toast } from 'sonner';
|
|
|
|
export default function ProductCreate({ categories }: { categories: Category[] }) {
|
|
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[],
|
|
prices: {
|
|
purchase: '',
|
|
distributor: '',
|
|
agent: '',
|
|
reseller: '',
|
|
retail: '',
|
|
},
|
|
thumbnail: null,
|
|
images: [],
|
|
});
|
|
|
|
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));
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6 p-6">
|
|
<Head title="Tambah Produk" />
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">Tambah Produk</h1>
|
|
</div>
|
|
<Link href={productRoutes.index().url}>
|
|
<Button variant='outline' className="gap-2">
|
|
<ArrowLeft className="size-4" />
|
|
Kembali
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
|
|
<form onSubmit={onSubmit} className="space-y-6">
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<div className="lg:col-span-2 space-y-6">
|
|
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
|
|
<CardHeader className="border-b">
|
|
<CardTitle>Informasi Produk</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<Field>
|
|
<Label htmlFor="name" required>Nama</Label>
|
|
<Input
|
|
id="name"
|
|
name="name"
|
|
value={data.name}
|
|
onChange={e => setData('name', e.target.value)}
|
|
autoComplete='off'
|
|
placeholder='Contoh: Gamis Wanita'
|
|
maxLength={100}
|
|
/>
|
|
<FieldError error={errors.name} label="Nama" className="text-xs" />
|
|
</Field>
|
|
|
|
<Field>
|
|
<Label htmlFor="category_ids" required>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>
|
|
<FieldError error={errors.category_ids} label="Kategori" className="text-xs mt-1" />
|
|
</Field>
|
|
|
|
<Field>
|
|
<Label htmlFor="description">Deskripsi</Label>
|
|
<SimpleEditor
|
|
value={data.description}
|
|
onChange={(val) => setData('description', val)}
|
|
/>
|
|
<FieldError error={errors.description} label="Deskripsi" className="text-xs mt-1" />
|
|
</Field>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
|
|
<CardHeader className="border-b">
|
|
<CardTitle>Harga Produk</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<Field>
|
|
<Label htmlFor="price_purchase" required>Harga Beli</Label>
|
|
<NumericFormat
|
|
id="price_purchase"
|
|
customInput={Input}
|
|
thousandSeparator="."
|
|
decimalSeparator=","
|
|
prefix="Rp "
|
|
value={data.prices.purchase}
|
|
onValueChange={(values) => {
|
|
setData('prices', {
|
|
...data.prices,
|
|
purchase: values.value
|
|
})
|
|
}}
|
|
placeholder="Rp 0"
|
|
autoComplete='off'
|
|
/>
|
|
<FieldError error={errors['prices.purchase']} label="Harga Beli" className="text-xs" />
|
|
</Field>
|
|
|
|
<Field>
|
|
<Label htmlFor="price_distributor" required>Harga Distributor</Label>
|
|
<NumericFormat
|
|
id="price_distributor"
|
|
customInput={Input}
|
|
thousandSeparator="."
|
|
decimalSeparator=","
|
|
prefix="Rp "
|
|
value={data.prices.distributor}
|
|
onValueChange={(values) => {
|
|
setData('prices', {
|
|
...data.prices,
|
|
distributor: values.value
|
|
})
|
|
}}
|
|
placeholder="Rp 0"
|
|
autoComplete='off'
|
|
/>
|
|
<FieldError error={errors['prices.distributor']} label="Harga Distributor" className="text-xs" />
|
|
</Field>
|
|
|
|
<Field>
|
|
<Label htmlFor="price_agent" required>Harga Agen</Label>
|
|
<NumericFormat
|
|
id="price_agent"
|
|
customInput={Input}
|
|
thousandSeparator="."
|
|
decimalSeparator=","
|
|
prefix="Rp "
|
|
value={data.prices.agent}
|
|
onValueChange={(values) => {
|
|
setData('prices', {
|
|
...data.prices,
|
|
agent: values.value
|
|
})
|
|
}}
|
|
placeholder="Rp 0"
|
|
autoComplete='off'
|
|
/>
|
|
<FieldError error={errors['prices.agent']} label="Harga Agen" className="text-xs" />
|
|
</Field>
|
|
|
|
<Field>
|
|
<Label htmlFor="price_reseller" required>Harga Reseller</Label>
|
|
<NumericFormat
|
|
id="price_reseller"
|
|
customInput={Input}
|
|
thousandSeparator="."
|
|
decimalSeparator=","
|
|
prefix="Rp "
|
|
value={data.prices.reseller}
|
|
onValueChange={(values) => {
|
|
setData('prices', {
|
|
...data.prices,
|
|
reseller: values.value
|
|
})
|
|
}}
|
|
placeholder="Rp 0"
|
|
autoComplete='off'
|
|
/>
|
|
<FieldError error={errors['prices.reseller']} label="Harga Reseller" className="text-xs" />
|
|
</Field>
|
|
|
|
<Field className="md:col-span-2">
|
|
<Label htmlFor="price_retail" required>Harga Retail</Label>
|
|
<NumericFormat
|
|
id="price_retail"
|
|
customInput={Input}
|
|
thousandSeparator="."
|
|
decimalSeparator=","
|
|
prefix="Rp "
|
|
value={data.prices.retail}
|
|
onValueChange={(values) => {
|
|
setData('prices', {
|
|
...data.prices,
|
|
retail: values.value
|
|
})
|
|
}}
|
|
placeholder="Rp 0"
|
|
autoComplete='off'
|
|
/>
|
|
<FieldError error={errors['prices.retail']} label="Harga Retail" className="text-xs" />
|
|
</Field>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
|
|
<CardHeader className="border-b">
|
|
<CardTitle>Thumbnail</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{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"
|
|
>
|
|
<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}
|
|
/>
|
|
<FieldError error={errors.thumbnail} label="Thumbnail" className="text-xs mt-2" />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
|
|
<CardHeader className="border-b">
|
|
<CardTitle>Galeri Gambar</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<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}
|
|
/>
|
|
<FieldError error={errors['images']} label="Galeri Gambar" className="text-xs" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
<Button type="submit" disabled={processing}>
|
|
{processing ? <Loader className="size-4 animate-spin" /> : <Save className="size-4" />}
|
|
{processing ? 'Menyimpan...' : 'Simpan'}
|
|
</Button>
|
|
</form>
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
ProductCreate.layout = {
|
|
breadcrumbs: [
|
|
{
|
|
title: 'Master',
|
|
},
|
|
],
|
|
};
|