dstpabuaran.com/resources/js/pages/admin/master/product/edit.tsx
Yoga Pangestu 50b2be68ac Add error handling with toast notifications for form submissions
- 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.
2026-08-07 08:50:11 +07:00

941 lines
51 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 { clearProductDraft } from '@/lib/product-draft';
import { index as productIndex, update } from '@/routes/admin/master/products';
type Category = {
id: number;
name: string;
};
type ProductVariant = {
id: number;
name: string;
stock: number;
reject_stock: number;
retail_stock: number;
photo_keys: string[];
photo_urls: string[];
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;
photos: Array<{ key: string; url: string | null }>;
uploading: boolean;
prices: Array<{ type: string; price: number }>;
};
export default function ProductEdit({ product, categories }: Props) {
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
const userId = auth.user?.id;
clearProductDraft('edit', userId, product.id);
const [productName, setProductName] = useState(product.name);
const [status, setStatus] = useState<string>(product.status);
const [description, setDescription] = useState(
product.description ?? '',
);
const serverVariants: 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,
photos: v.photo_keys.map((key, i) => ({
key,
url: v.photo_urls[i] ?? null,
})),
uploading: false,
prices: v.prices.length > 0 ? v.prices : createEmptyPrices(),
}),
);
const [categoryIds, setCategoryIds] = useState<number[]>(
product.category_ids,
);
const [useSamePrice, setUseSamePrice] = useState(
serverVariants.length > 1
? serverVariants.every((v) =>
arePricesEqual(v.prices, serverVariants[0].prices),
)
: true,
);
const [sharedPrices, setSharedPrices] = useState<
Array<{ type: string; price: number }>
>(
serverVariants.length > 0
? serverVariants[0].prices
: createEmptyPrices(),
);
const [variants, setVariants] = useState<VariantState[]>(() => {
return serverVariants.length > 0
? serverVariants
: [
{
id: null,
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) => ({
id: v.id,
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('edit', draftData, userId, product.id);
const addVariant = useCallback(() => {
setVariants((prev) => [
...prev,
{
id: null,
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) => ({
id: v.id,
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="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">
<Link href={productIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
</div>
<Form
action={update(product.id)}
method="put"
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-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 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);
}}
/>
</>
);
}