feat: implement product draft functionality for create and edit pages

This commit is contained in:
Yoga Pangestu 2026-08-01 14:40:06 +07:00
parent 0126321a6e
commit 841767d5b1
5 changed files with 348 additions and 49 deletions

View File

@ -29,7 +29,9 @@ public function update(ProductVariantRequest $request, Product $product, Product
return $this->handleAction(
fn () => $this->variantService->update($variant, $request->validated()),
'Varian berhasil diperbarui.',
'admin.master.products.index'
'admin.master.products.index',
'admin.master.products.variants.edit',
['product' => $product, 'variant' => $variant]
);
}

View File

@ -0,0 +1,68 @@
import { useEffect, useRef, useCallback } from 'react';
import {
saveProductDraft,
clearProductDraft,
type ProductDraftData,
} from '@/lib/product-draft';
type DraftType = 'create' | 'edit';
export function useProductDraftSave(
type: DraftType,
data: ProductDraftData,
userId?: number,
productId?: number,
delay = 500,
) {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dataRef = useRef(data);
dataRef.current = data;
const flush = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
const ok = saveProductDraft(type, dataRef.current, userId, productId);
return ok;
}, [type, userId, productId]);
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
saveProductDraft(type, dataRef.current, userId, productId);
timeoutRef.current = null;
}, delay);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [data, type, userId, productId, delay]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
saveProductDraft(type, dataRef.current, userId, productId);
};
}, [type, userId, productId]);
return { flush };
}
export function useProductDraftClear(
type: DraftType,
userId?: number,
productId?: number,
) {
useEffect(() => {
return () => {
clearProductDraft(type, userId, productId);
};
}, [type, userId, productId]);
}

View File

@ -0,0 +1,73 @@
const DRAFT_PREFIX = 'product-draft';
export type ProductDraftData = {
productName: string;
status: string;
description: string;
categoryIds: number[];
useSamePrice: boolean;
sharedPrices: Array<{ type: string; price: number }>;
variants: Array<{
id?: number | null;
name: string;
stock: number;
reject_stock: number;
retail_stock: number;
photo: string | null;
prices: Array<{ type: string; price: number }>;
}>;
};
function getKey(
type: 'create' | 'edit',
userId?: number,
productId?: number,
): string {
if (type === 'edit' && productId) {
return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}-${productId}`;
}
return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`;
}
export function saveProductDraft(
type: 'create' | 'edit',
data: ProductDraftData,
userId?: number,
productId?: number,
): boolean {
try {
const key = getKey(type, userId, productId);
localStorage.setItem(key, JSON.stringify(data));
return true;
} catch {
return false;
}
}
export function loadProductDraft(
type: 'create' | 'edit',
userId?: number,
productId?: number,
): ProductDraftData | null {
try {
const key = getKey(type, userId, productId);
const raw = localStorage.getItem(key);
if (!raw) return null;
return JSON.parse(raw) as ProductDraftData;
} catch {
return null;
}
}
export function clearProductDraft(
type: 'create' | 'edit',
userId?: number,
productId?: number,
): void {
try {
const key = getKey(type, userId, productId);
localStorage.removeItem(key);
} catch {
// ignore
}
}

View File

@ -8,8 +8,11 @@ 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 { loadProductDraft, clearProductDraft } from '@/lib/product-draft';
import { useProductDraftSave } from '@/hooks/use-product-draft';
import { getTemporaryUrl } from '@/lib/upload';
import { index as productIndex, store } from '@/routes/admin/master/products';
import { Form, Head } from '@inertiajs/react';
import { Form, Head, router, usePage } from '@inertiajs/react';
import {
ArrowLeft,
Copy,
@ -18,7 +21,7 @@ import {
Plus,
Trash2,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
type Category = {
id: number;
@ -51,30 +54,72 @@ type VariantState = {
reject_stock: number;
retail_stock: number;
photo: string | null;
photoUrl: string | null;
uploading: boolean;
prices: Array<{ type: string; price: number }>;
};
export default function ProductCreate({ categories }: Props) {
const [categoryIds, setCategoryIds] = useState<number[]>([]);
const [useSamePrice, setUseSamePrice] = useState(true);
const [sharedPrices, setSharedPrices] =
useState<Array<{ type: string; price: number }>>(createEmptyPrices());
const [variants, setVariants] = useState<VariantState[]>([
{
name: '',
stock: 0,
reject_stock: 0,
retail_stock: 0,
photo: null,
uploading: false,
prices: createEmptyPrices(),
},
]);
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) => ({
...v,
photoUrl: v.photo ? getTemporaryUrl(v.photo) : null,
uploading: false,
}));
}
return [
{
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 draftData = {
productName,
status,
description,
categoryIds,
useSamePrice,
sharedPrices,
variants: variants.map(({ uploading, photoUrl, ...v }) => v),
};
useProductDraftSave('create', draftData, userId);
useEffect(() => {
return router.on('success', () => {
clearProductDraft('create', userId);
});
}, [userId]);
const addVariant = useCallback(() => {
setVariants((prev) => [
...prev,
@ -84,6 +129,7 @@ export default function ProductCreate({ categories }: Props) {
reject_stock: 0,
retail_stock: 0,
photo: null,
photoUrl: null,
uploading: false,
prices: createEmptyPrices(),
},
@ -185,6 +231,9 @@ export default function ProductCreate({ categories }: Props) {
function getPayload() {
return {
name: productName,
status,
description,
category_ids: categoryIds,
use_same_price: useSamePrice,
shared_prices: useSamePrice
@ -251,6 +300,12 @@ export default function ProductCreate({ categories }: Props) {
<Input
id="name"
name="name"
value={productName}
onChange={(e) =>
setProductName(
e.target.value,
)
}
placeholder="Masukkan nama produk"
/>
<InputError message={errors.name} />
@ -264,7 +319,8 @@ export default function ProductCreate({ categories }: Props) {
</Label>
<RadioGroup
name="status"
defaultValue="active"
value={status}
onValueChange={setStatus}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
@ -365,6 +421,12 @@ export default function ProductCreate({ categories }: Props) {
<Textarea
id="description"
name="description"
value={description}
onChange={(e) =>
setDescription(
e.target.value,
)
}
placeholder="Masukkan deskripsi produk"
rows={3}
/>
@ -682,13 +744,27 @@ export default function ProductCreate({ categories }: Props) {
value={
variant.photo
}
onChange={(photo) =>
existingUrl={
variant.photoUrl
}
onChange={(
photo,
) => {
updateVariant(
variantIndex,
'photo',
photo,
)
}
);
updateVariant(
variantIndex,
'photoUrl',
photo
? getTemporaryUrl(
photo,
)
: null,
);
}}
folder="product-variant"
onUploadingChange={(
uploading,

View File

@ -8,8 +8,11 @@ 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 { loadProductDraft, clearProductDraft } from '@/lib/product-draft';
import { useProductDraftSave } from '@/hooks/use-product-draft';
import { getTemporaryUrl } from '@/lib/upload';
import { index as productIndex, update } from '@/routes/admin/master/products';
import { Form, Head } from '@inertiajs/react';
import { Form, Head, router, usePage } from '@inertiajs/react';
import {
ArrowLeft,
Copy,
@ -18,7 +21,7 @@ import {
Plus,
Trash2,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
type Category = {
id: number;
@ -87,7 +90,20 @@ type VariantState = {
};
export default function ProductEdit({ product, categories }: Props) {
const initialVariants: VariantState[] = product.product_variants.map(
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
const userId = auth.user?.id;
const draft = loadProductDraft('edit', userId, product.id);
const [productName, setProductName] = useState(
draft?.productName ?? product.name,
);
const [status, setStatus] = useState(draft?.status ?? product.status);
const [description, setDescription] = useState(
draft?.description ?? product.description ?? '',
);
const serverVariants: VariantState[] = product.product_variants.map(
(v) => ({
id: v.id,
name: v.name,
@ -101,27 +117,50 @@ export default function ProductEdit({ product, categories }: Props) {
}),
);
const allSamePrice =
initialVariants.length > 1
? initialVariants.every((v) =>
arePricesEqual(v.prices, initialVariants[0].prices),
)
: true;
const [categoryIds, setCategoryIds] = useState<number[]>(
product.category_ids,
draft?.categoryIds ?? product.category_ids,
);
const [useSamePrice, setUseSamePrice] = useState(
draft?.useSamePrice ??
(serverVariants.length > 1
? serverVariants.every((v) =>
arePricesEqual(v.prices, serverVariants[0].prices),
)
: true),
);
const [useSamePrice, setUseSamePrice] = useState(allSamePrice);
const [sharedPrices, setSharedPrices] = useState<
Array<{ type: string; price: number }>
>(
initialVariants.length > 0
? initialVariants[0].prices
: createEmptyPrices(),
draft?.sharedPrices ??
(serverVariants.length > 0
? serverVariants[0].prices
: createEmptyPrices()),
);
const [variants, setVariants] = useState<VariantState[]>(
initialVariants.length > 0
? initialVariants
const [variants, setVariants] = useState<VariantState[]>(() => {
if (draft?.variants && draft.variants.length > 0) {
const serverVariantMap = new Map(
serverVariants.map((sv) => [sv.id, sv]),
);
return draft.variants.map((v) => {
const serverMatch =
v.id != null ? serverVariantMap.get(v.id) : undefined;
return {
id: v.id ?? null,
name: v.name,
stock: v.stock,
reject_stock: v.reject_stock,
retail_stock: v.retail_stock,
photo: v.photo,
photoUrl:
serverMatch?.photoUrl ??
(v.photo ? getTemporaryUrl(v.photo) : null),
uploading: false,
prices: v.prices,
};
});
}
return serverVariants.length > 0
? serverVariants
: [
{
id: null,
@ -134,12 +173,30 @@ export default function ProductEdit({ product, categories }: Props) {
uploading: false,
prices: createEmptyPrices(),
},
],
);
];
});
const variantsRef = useRef(variants);
variantsRef.current = variants;
const draftData = {
productName,
status,
description,
categoryIds,
useSamePrice,
sharedPrices,
variants: variants.map(({ uploading, photoUrl, ...v }) => v),
};
useProductDraftSave('edit', draftData, userId, product.id);
useEffect(() => {
return router.on('success', () => {
clearProductDraft('edit', userId, product.id);
});
}, [userId, product.id]);
const addVariant = useCallback(() => {
setVariants((prev) => [
...prev,
@ -252,6 +309,9 @@ export default function ProductEdit({ product, categories }: Props) {
function getPayload() {
return {
name: productName,
status,
description,
category_ids: categoryIds,
use_same_price: useSamePrice,
shared_prices: useSamePrice
@ -320,7 +380,12 @@ export default function ProductEdit({ product, categories }: Props) {
<Input
id="name"
name="name"
defaultValue={product.name}
value={productName}
onChange={(e) =>
setProductName(
e.target.value,
)
}
placeholder="Masukkan nama produk"
/>
<InputError message={errors.name} />
@ -334,7 +399,8 @@ export default function ProductEdit({ product, categories }: Props) {
</Label>
<RadioGroup
name="status"
defaultValue={product.status}
value={status}
onValueChange={setStatus}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
@ -435,8 +501,11 @@ export default function ProductEdit({ product, categories }: Props) {
<Textarea
id="description"
name="description"
defaultValue={
product.description ?? ''
value={description}
onChange={(e) =>
setDescription(
e.target.value,
)
}
placeholder="Masukkan deskripsi produk"
rows={3}
@ -755,13 +824,24 @@ export default function ProductEdit({ product, categories }: Props) {
value={
variant.photo
}
onChange={(photo) =>
onChange={(
photo,
) => {
updateVariant(
variantIndex,
'photo',
photo,
)
}
);
updateVariant(
variantIndex,
'photoUrl',
photo
? getTemporaryUrl(
photo,
)
: null,
);
}}
folder="product-variant"
existingUrl={
variant.photoUrl