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

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>
</>
);
}