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

529 lines
28 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="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"
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="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>
</>
);
}
ProductEdit.layout = {
breadcrumbs: [
{
title: 'Master',
href: productIndex.url(),
},
{
title: 'Produk',
href: productIndex.url(),
},
{
title: 'Edit',
href: '#',
},
],
};