475 lines
26 KiB
TypeScript
475 lines
26 KiB
TypeScript
import InputError from '@/components/input-error';
|
|
import { RupiahInput } from '@/components/rupiah-input';
|
|
import { FileUpload } from '@/components/file-upload';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { index as productIndex, store } from '@/routes/admin/master/products';
|
|
import { Form, Head } from '@inertiajs/react';
|
|
import { ArrowLeft, Copy, ClipboardPaste, Check, Plus, Trash2 } from 'lucide-react';
|
|
import { useCallback, useRef, useState } from 'react';
|
|
|
|
type Category = {
|
|
id: number;
|
|
name: string;
|
|
};
|
|
|
|
type Props = {
|
|
categories: Category[];
|
|
};
|
|
|
|
const PRICE_TYPES = [
|
|
{ key: 'distributor', label: 'Distributor' },
|
|
{ key: 'agent', label: 'Agen' },
|
|
{ key: 'sub_agent', label: 'Sub Agen' },
|
|
{ key: 'wholesale', label: 'Grosir' },
|
|
{ key: 'retail', label: 'Ecer' },
|
|
{ key: 'tiktok', label: 'TikTok' },
|
|
{ key: 'shopee', label: 'Shopee' },
|
|
{ key: 'capital', label: 'Modal' },
|
|
{ key: 'reject', label: 'Reject' },
|
|
];
|
|
|
|
function createEmptyPrices(): Array<{ type: string; price: number }> {
|
|
return PRICE_TYPES.map((pt) => ({ type: pt.key, price: 0 }));
|
|
}
|
|
|
|
type VariantState = {
|
|
name: string;
|
|
stock: number;
|
|
reject_stock: number;
|
|
retail_stock: number;
|
|
photo: string | null;
|
|
uploading: boolean;
|
|
prices: Array<{ type: string; price: number }>;
|
|
};
|
|
|
|
export default function ProductCreate({ categories }: Props) {
|
|
const [categoryIds, setCategoryIds] = useState<number[]>([]);
|
|
const [useSamePrice, setUseSamePrice] = useState(true);
|
|
const [sharedPrices, setSharedPrices] = useState<Array<{ type: string; price: number }>>(createEmptyPrices());
|
|
const [variants, setVariants] = useState<VariantState[]>([
|
|
{
|
|
name: '',
|
|
stock: 0,
|
|
reject_stock: 0,
|
|
retail_stock: 0,
|
|
photo: null,
|
|
uploading: false,
|
|
prices: createEmptyPrices(),
|
|
},
|
|
]);
|
|
|
|
const variantsRef = useRef(variants);
|
|
variantsRef.current = variants;
|
|
|
|
const addVariant = useCallback(() => {
|
|
setVariants((prev) => [
|
|
...prev,
|
|
{
|
|
name: '',
|
|
stock: 0,
|
|
reject_stock: 0,
|
|
retail_stock: 0,
|
|
photo: null,
|
|
uploading: false,
|
|
prices: createEmptyPrices(),
|
|
},
|
|
]);
|
|
}, []);
|
|
|
|
const removeVariant = useCallback((index: number) => {
|
|
setVariants((prev) => prev.filter((_, i) => i !== index));
|
|
}, []);
|
|
|
|
const updateVariant = useCallback((index: number, field: keyof VariantState, value: unknown) => {
|
|
setVariants((prev) => {
|
|
const updated = [...prev];
|
|
(updated[index] as Record<string, unknown>)[field] = value;
|
|
return updated;
|
|
});
|
|
}, []);
|
|
|
|
const updateVariantPrice = useCallback((variantIndex: number, priceIndex: number, value: number) => {
|
|
setVariants((prev) => {
|
|
const updated = [...prev];
|
|
updated[variantIndex] = {
|
|
...updated[variantIndex],
|
|
prices: updated[variantIndex].prices.map((p, i) =>
|
|
i === priceIndex ? { ...p, price: value } : p
|
|
),
|
|
};
|
|
return updated;
|
|
});
|
|
}, []);
|
|
|
|
const updateSharedPrice = useCallback((priceIndex: number, value: number) => {
|
|
setSharedPrices((prev) => {
|
|
const updated = [...prev];
|
|
updated[priceIndex] = { ...updated[priceIndex], price: value };
|
|
return updated;
|
|
});
|
|
}, []);
|
|
|
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
|
|
|
const copyPrices = useCallback((variantIndex: number) => {
|
|
setVariants((prev) => {
|
|
const prices = prev[variantIndex].prices;
|
|
navigator.clipboard.writeText(JSON.stringify(prices));
|
|
setCopiedIndex(variantIndex);
|
|
setTimeout(() => setCopiedIndex(null), 1500);
|
|
return prev;
|
|
});
|
|
}, []);
|
|
|
|
const pastePrices = useCallback((variantIndex: number) => {
|
|
navigator.clipboard.readText().then((text) => {
|
|
try {
|
|
const prices = JSON.parse(text) as Array<{ type: string; price: number }>;
|
|
setVariants((prev) => {
|
|
const updated = [...prev];
|
|
updated[variantIndex] = { ...updated[variantIndex], prices };
|
|
return updated;
|
|
});
|
|
} catch {
|
|
// invalid clipboard data
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
const applyToAll = useCallback((variantIndex: number) => {
|
|
setVariants((prev) => {
|
|
const sourcePrices = prev[variantIndex].prices;
|
|
return prev.map((v, i) =>
|
|
i === variantIndex ? v : { ...v, prices: [...sourcePrices] }
|
|
);
|
|
});
|
|
}, []);
|
|
|
|
function getPayload() {
|
|
return {
|
|
category_ids: categoryIds,
|
|
use_same_price: useSamePrice,
|
|
shared_prices: useSamePrice ? sharedPrices.map((p) => ({
|
|
type: p.type,
|
|
price: Number(p.price),
|
|
})) : [],
|
|
variants: variantsRef.current.map((v) => ({
|
|
name: v.name,
|
|
stock: Number(v.stock),
|
|
reject_stock: Number(v.reject_stock),
|
|
retail_stock: Number(v.retail_stock),
|
|
photo_key: v.photo,
|
|
prices: useSamePrice
|
|
? []
|
|
: v.prices.map((p) => ({ type: p.type, price: Number(p.price) })),
|
|
})),
|
|
};
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Head title="Tambah Produk" />
|
|
|
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-2xl font-semibold tracking-tight">Tambah Produk</h2>
|
|
<Button asChild variant="outline">
|
|
<a href={productIndex.url()}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Kembali
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
|
|
<Form
|
|
action={store()}
|
|
transform={(data) => ({
|
|
...data,
|
|
...getPayload(),
|
|
})}
|
|
>
|
|
{({ errors, processing }) => (
|
|
<>
|
|
<div className="grid gap-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Informasi Produk</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="name">
|
|
Nama Produk <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="name"
|
|
name="name"
|
|
placeholder="Masukkan nama produk"
|
|
/>
|
|
<InputError message={errors.name} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label>Status <span className="text-destructive">*</span></Label>
|
|
<RadioGroup name="status" defaultValue="active" className="flex gap-4">
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="active" id="status-active" />
|
|
<Label htmlFor="status-active" className="font-normal">Aktif</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="inactive" id="status-inactive" />
|
|
<Label htmlFor="status-inactive" className="font-normal">Non Aktif</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="draft" id="status-draft" />
|
|
<Label htmlFor="status-draft" className="font-normal">Draft</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
<InputError message={errors.status} />
|
|
</div>
|
|
<div className="grid gap-2 md:col-span-2">
|
|
<Label>Kategori <span className="text-destructive">*</span></Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{categories.map((category) => (
|
|
<label
|
|
key={category.id}
|
|
className="flex items-center space-x-2 rounded-md border px-3 py-2 text-sm"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
name="category_ids[]"
|
|
value={category.id}
|
|
checked={categoryIds.includes(category.id)}
|
|
onChange={(e) => {
|
|
setCategoryIds((prev) =>
|
|
e.target.checked
|
|
? [...prev, category.id]
|
|
: prev.filter((id) => id !== category.id)
|
|
);
|
|
}}
|
|
className="rounded"
|
|
/>
|
|
<span>{category.name}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<InputError message={errors.category_ids} />
|
|
</div>
|
|
<div className="grid gap-2 md:col-span-2">
|
|
<Label htmlFor="description">Deskripsi</Label>
|
|
<Textarea
|
|
id="description"
|
|
name="description"
|
|
placeholder="Masukkan deskripsi produk"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Harga</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<RadioGroup
|
|
name="use_same_price"
|
|
value={useSamePrice ? '1' : '0'}
|
|
onValueChange={(val) => setUseSamePrice(val === '1')}
|
|
className="flex gap-6"
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="1" id="price-same" />
|
|
<Label htmlFor="price-same" className="font-normal">Semua varian sama</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="0" id="price-different" />
|
|
<Label htmlFor="price-different" className="font-normal">Harga per varian</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
|
|
{useSamePrice && (
|
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
|
{PRICE_TYPES.map((priceType, priceIndex) => (
|
|
<div key={priceType.key} className="grid gap-2">
|
|
<Label>Harga {priceType.label} <span className="text-destructive">*</span></Label>
|
|
<RupiahInput
|
|
value={sharedPrices[priceIndex]?.price ?? 0}
|
|
onValueChange={(val) => updateSharedPrice(priceIndex, val)}
|
|
/>
|
|
<InputError message={errors[`shared_prices.${priceIndex}.price`]} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Varian Produk</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{variants.map((variant, variantIndex) => (
|
|
<div key={variantIndex} className="rounded-lg border p-4 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="font-medium">Varian {variantIndex + 1}</h4>
|
|
<div className="flex items-center gap-1">
|
|
{!useSamePrice && (
|
|
<>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => copyPrices(variantIndex)}
|
|
>
|
|
{copiedIndex === variantIndex ? (
|
|
<Check className="h-4 w-4 text-green-600" />
|
|
) : (
|
|
<Copy className="h-4 w-4" />
|
|
)}
|
|
Salin
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => pastePrices(variantIndex)}
|
|
>
|
|
<ClipboardPaste className="h-4 w-4" />
|
|
Tempel
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => applyToAll(variantIndex)}
|
|
>
|
|
Terapkan ke Semua
|
|
</Button>
|
|
</>
|
|
)}
|
|
{variantIndex > 0 && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => removeVariant(variantIndex)}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
|
<div className="grid gap-2">
|
|
<Label>Nama Varian <span className="text-destructive">*</span></Label>
|
|
<Input
|
|
value={variant.name}
|
|
onChange={(e) => updateVariant(variantIndex, 'name', e.target.value)}
|
|
placeholder="Contoh: Ukuran L, Warna Merah"
|
|
/>
|
|
<InputError message={errors[`variants.${variantIndex}.name`]} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label>Stok Bagus <span className="text-destructive">*</span></Label>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={variant.stock}
|
|
onChange={(e) => updateVariant(variantIndex, 'stock', Number(e.target.value))}
|
|
/>
|
|
<InputError message={errors[`variants.${variantIndex}.stock`]} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label>Stok Reject <span className="text-destructive">*</span></Label>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={variant.reject_stock}
|
|
onChange={(e) => updateVariant(variantIndex, 'reject_stock', Number(e.target.value))}
|
|
/>
|
|
<InputError message={errors[`variants.${variantIndex}.reject_stock`]} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label>Stok Ecer <span className="text-destructive">*</span></Label>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={variant.retail_stock}
|
|
onChange={(e) => updateVariant(variantIndex, 'retail_stock', Number(e.target.value))}
|
|
/>
|
|
<InputError message={errors[`variants.${variantIndex}.retail_stock`]} />
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label>Foto Varian <span className="text-destructive">*</span></Label>
|
|
<FileUpload
|
|
value={variant.photo}
|
|
onChange={(photo) => updateVariant(variantIndex, 'photo', photo)}
|
|
folder="product-variant"
|
|
onUploadingChange={(uploading) => updateVariant(variantIndex, 'uploading', uploading)}
|
|
/>
|
|
<InputError message={errors[`variants.${variantIndex}.photo_key`]} />
|
|
</div>
|
|
{!useSamePrice && (
|
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
|
{PRICE_TYPES.map((priceType, priceIndex) => (
|
|
<div key={priceType.key} className="grid gap-2">
|
|
<Label>Harga {priceType.label} <span className="text-destructive">*</span></Label>
|
|
<RupiahInput
|
|
value={variant.prices[priceIndex]?.price ?? 0}
|
|
onValueChange={(val) => updateVariantPrice(variantIndex, priceIndex, val)}
|
|
/>
|
|
<InputError message={errors[`variants.${variantIndex}.prices.${priceIndex}.price`]} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
<Button type="button" variant="outline" onClick={addVariant}>
|
|
<Plus className="h-4 w-4" />
|
|
Tambah Varian
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4 mt-6">
|
|
<Button type="submit" disabled={processing || variants.some((v) => v.uploading)}>
|
|
{processing
|
|
? 'Menyimpan...'
|
|
: variants.some((v) => v.uploading)
|
|
? 'Mengunggah...'
|
|
: 'Simpan'}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Form>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
ProductCreate.layout = {
|
|
breadcrumbs: [
|
|
{
|
|
title: 'Master',
|
|
href: productIndex.url(),
|
|
},
|
|
{
|
|
title: 'Produk',
|
|
href: productIndex.url(),
|
|
},
|
|
{
|
|
title: 'Tambah',
|
|
href: '#',
|
|
},
|
|
],
|
|
};
|