867 lines
49 KiB
TypeScript
867 lines
49 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, update } 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 ProductVariant = {
|
|
id: number;
|
|
name: string;
|
|
stock: number;
|
|
reject_stock: number;
|
|
retail_stock: number;
|
|
photo_key: string | null;
|
|
photo_url: string | null;
|
|
prices: Array<{ type: string; price: number }>;
|
|
};
|
|
|
|
type Props = {
|
|
product: {
|
|
id: number;
|
|
name: string;
|
|
description: string | null;
|
|
status: 'active' | 'inactive' | 'draft';
|
|
category_ids: number[];
|
|
product_variants: ProductVariant[];
|
|
};
|
|
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 }));
|
|
}
|
|
|
|
function arePricesEqual(
|
|
a: Array<{ type: string; price: number }>,
|
|
b: Array<{ type: string; price: number }>,
|
|
): boolean {
|
|
if (a.length !== b.length) return false;
|
|
return a.every(
|
|
(pa, i) => pa.type === b[i]?.type && pa.price === b[i]?.price,
|
|
);
|
|
}
|
|
|
|
type VariantState = {
|
|
id: number | null;
|
|
name: string;
|
|
stock: number;
|
|
reject_stock: number;
|
|
retail_stock: number;
|
|
photo: string | null;
|
|
photoUrl: string | null;
|
|
uploading: boolean;
|
|
prices: Array<{ type: string; price: number }>;
|
|
};
|
|
|
|
export default function ProductEdit({ product, categories }: Props) {
|
|
const initialVariants: VariantState[] = product.product_variants.map(
|
|
(v) => ({
|
|
id: v.id,
|
|
name: v.name,
|
|
stock: v.stock,
|
|
reject_stock: v.reject_stock,
|
|
retail_stock: v.retail_stock,
|
|
photo: v.photo_key,
|
|
photoUrl: v.photo_url,
|
|
uploading: false,
|
|
prices: v.prices.length > 0 ? v.prices : createEmptyPrices(),
|
|
}),
|
|
);
|
|
|
|
const allSamePrice =
|
|
initialVariants.length > 1
|
|
? initialVariants.every((v) =>
|
|
arePricesEqual(v.prices, initialVariants[0].prices),
|
|
)
|
|
: true;
|
|
|
|
const [categoryIds, setCategoryIds] = useState<number[]>(
|
|
product.category_ids,
|
|
);
|
|
const [useSamePrice, setUseSamePrice] = useState(allSamePrice);
|
|
const [sharedPrices, setSharedPrices] = useState<
|
|
Array<{ type: string; price: number }>
|
|
>(
|
|
initialVariants.length > 0
|
|
? initialVariants[0].prices
|
|
: createEmptyPrices(),
|
|
);
|
|
const [variants, setVariants] = useState<VariantState[]>(
|
|
initialVariants.length > 0
|
|
? initialVariants
|
|
: [
|
|
{
|
|
id: null,
|
|
name: '',
|
|
stock: 0,
|
|
reject_stock: 0,
|
|
retail_stock: 0,
|
|
photo: null,
|
|
photoUrl: null,
|
|
uploading: false,
|
|
prices: createEmptyPrices(),
|
|
},
|
|
],
|
|
);
|
|
|
|
const variantsRef = useRef(variants);
|
|
variantsRef.current = variants;
|
|
|
|
const addVariant = useCallback(() => {
|
|
setVariants((prev) => [
|
|
...prev,
|
|
{
|
|
id: null,
|
|
name: '',
|
|
stock: 0,
|
|
reject_stock: 0,
|
|
retail_stock: 0,
|
|
photo: null,
|
|
photoUrl: 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) => ({
|
|
id: v.id,
|
|
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="Edit 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">
|
|
Edit Produk
|
|
</h2>
|
|
<Button asChild variant="outline">
|
|
<a href={productIndex.url()}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Kembali
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
|
|
<Form
|
|
action={update(product.id)}
|
|
method="put"
|
|
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"
|
|
defaultValue={product.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={product.status}
|
|
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"
|
|
defaultValue={
|
|
product.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-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={
|
|
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="space-y-4 rounded-lg border p-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"
|
|
existingUrl={
|
|
variant.photoUrl
|
|
}
|
|
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="mt-6 flex items-center gap-4">
|
|
<Button
|
|
type="submit"
|
|
disabled={
|
|
processing ||
|
|
variants.some((v) => v.uploading)
|
|
}
|
|
>
|
|
{processing
|
|
? 'Menyimpan...'
|
|
: variants.some((v) => v.uploading)
|
|
? 'Mengunggah...'
|
|
: 'Simpan'}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Form>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|