- Integrated toast notifications to display error messages when form submissions fail across various components, enhancing user feedback and experience. - Updated components including DeleteUser, FormDialog, ManageTwoFactor, TwoFactorRecoveryCodes, TwoFactorSetupModal, PayrollPeriodShow, EmployeeCreate, EmployeeEdit, CuttingCreate, CuttingEdit, PurchaseCreate, PurchaseEdit, RestockCreate, RestockEdit, TransactionCreate, TransactionEdit, Category management, Product management, Raw Material management, Role management, Settings, and Authentication pages.
891 lines
50 KiB
TypeScript
891 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/confirm-dialog';
|
|
import { FileUploadMultiple } from '@/components/file-upload-multiple';
|
|
import InputError from '@/components/input-error';
|
|
import { NumberInput } from '@/components/number-input';
|
|
import { RupiahInput } from '@/components/rupiah-input';
|
|
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', 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;
|
|
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('Terjadi kesalahan saat menyimpan data. 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>
|
|
</>
|
|
)}
|
|
{variantIndex >
|
|
0 && (
|
|
<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);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|