dstpabuaran.com/resources/js/pages/admin/master/product/create.tsx

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