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

892 lines
50 KiB
TypeScript

import { Form, Head, Link, usePage } from '@inertiajs/react';
import {
ArrowLeft,
Copy,
ClipboardPaste,
Check,
Plus,
Trash2,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs';
import { FileUploadMultiple } from '@/components/inputs';
import { InputError } from '@/components/ui';
import { NumberInput } from '@/components/inputs';
import { RupiahInput } from '@/components/inputs';
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 { useProductDraftSave } from '@/hooks/use-product-draft';
import { loadProductDraft } from '@/lib/product-draft';
import { getTemporaryUrl } from '@/lib/upload';
import { index as productIndex, store } from '@/routes/admin/master/products';
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_capital', label: 'Reject Modal' },
{ key: 'reject_selling', label: 'Reject Jual' },
];
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;
photos: Array<{ key: string; url: string | null }>;
uploading: boolean;
prices: Array<{ type: string; price: number }>;
};
export default function ProductCreate({ categories }: Props) {
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
const userId = auth.user?.id;
const draft = loadProductDraft('create', userId);
const [productName, setProductName] = useState(draft?.productName ?? '');
const [status, setStatus] = useState(draft?.status ?? 'active');
const [description, setDescription] = useState(draft?.description ?? '');
const [categoryIds, setCategoryIds] = useState<number[]>(
draft?.categoryIds ?? [],
);
const [useSamePrice, setUseSamePrice] = useState(
draft?.useSamePrice ?? true,
);
const [sharedPrices, setSharedPrices] = useState<
Array<{ type: string; price: number }>
>(draft?.sharedPrices ?? createEmptyPrices());
const [variants, setVariants] = useState<VariantState[]>(() => {
if (draft?.variants && draft.variants.length > 0) {
return draft.variants.map((v) => {
const photos = Array.isArray(v.photos)
? v.photos.map((p) => ({ key: p.key, url: getTemporaryUrl(p.key) }))
: typeof v.photo === 'string' && v.photo
? [{ key: v.photo, url: getTemporaryUrl(v.photo) }]
: [];
return {
...v,
photos,
uploading: false,
};
});
}
return [
{
name: '',
stock: 0,
reject_stock: 0,
retail_stock: 0,
photos: [],
uploading: false,
prices: createEmptyPrices(),
},
];
});
const variantsRef = useRef(variants);
variantsRef.current = variants;
const draftData = {
productName,
status,
description,
categoryIds,
useSamePrice,
sharedPrices,
variants: variants.map((v) => ({
name: v.name,
stock: v.stock,
reject_stock: v.reject_stock,
retail_stock: v.retail_stock,
photos: v.photos.map((p) => ({ key: p.key })),
prices: v.prices,
})),
};
useProductDraftSave('create', draftData, userId);
const addVariant = useCallback(() => {
setVariants((prev) => [
...prev,
{
name: '',
stock: 0,
reject_stock: 0,
retail_stock: 0,
photos: [],
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 [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(
null,
);
const confirmRemoveVariant = useCallback((index: number) => {
setDeleteVariantIndex(index);
setDeleteConfirmOpen(true);
}, []);
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 {
name: productName,
status,
description,
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_keys: v.photos.map((p) => p.key),
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">
<Link href={productIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
</div>
<Form
action={store()}
transform={(data) => ({
...data,
...getPayload(),
})}
onError={() => {
toast.error('Ada data yang belum sesuai, silakan periksa kembali input Anda.');
}}
>
{({ 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"
value={productName}
onChange={(e) =>
setProductName(
e.target.value,
)
}
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"
value={status}
onValueChange={setStatus}
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"
value={description}
onChange={(e) =>
setDescription(
e.target.value,
)
}
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="space-y-4 rounded-lg border p-4"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 className="font-medium">
Varian{' '}
{variantIndex + 1}
</h4>
<div className="flex flex-wrap items-center gap-1">
{!useSamePrice && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
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"
className="whitespace-nowrap"
onClick={() =>
pastePrices(
variantIndex,
)
}
>
<ClipboardPaste className="h-4 w-4" />
Tempel
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() =>
applyToAll(
variantIndex,
)
}
>
Terapkan
ke Semua
</Button>
</>
)}
{variants.length >
1 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() =>
confirmRemoveVariant(
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>
<NumberInput
value={
variant.stock
}
onValueChange={(
val,
) =>
updateVariant(
variantIndex,
'stock',
val,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.stock`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Stok Reject{' '}
<span className="text-destructive">
*
</span>
</Label>
<NumberInput
value={
variant.reject_stock
}
onValueChange={(
val,
) =>
updateVariant(
variantIndex,
'reject_stock',
val,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.reject_stock`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Stok Ecer{' '}
<span className="text-destructive">
*
</span>
</Label>
<NumberInput
value={
variant.retail_stock
}
onValueChange={(
val,
) =>
updateVariant(
variantIndex,
'retail_stock',
val,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.retail_stock`
]
}
/>
</div>
</div>
<div className="grid gap-2">
<Label>
Foto Varian{' '}
<span className="text-destructive">
*
</span>
</Label>
<FileUploadMultiple
value={
variant.photos
}
onChange={(
photos,
) =>
updateVariant(
variantIndex,
'photos',
photos,
)
}
folder="product-variant"
maxItems={5}
onUploadingChange={(
uploading,
) =>
updateVariant(
variantIndex,
'uploading',
uploading,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.photo_keys`
]
}
/>
</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>
<ConfirmDialog
open={deleteConfirmOpen}
onOpenChange={setDeleteConfirmOpen}
title="Hapus Varian"
description="Apakah Anda yakin ingin menghapus varian ini?"
confirmLabel="Hapus"
onConfirm={() => {
if (deleteVariantIndex !== null) {
removeVariant(deleteVariantIndex);
}
setDeleteConfirmOpen(false);
setDeleteVariantIndex(null);
}}
/>
</>
);
}