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

View File

@ -8,8 +8,11 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea'; 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 { index as productIndex, update } from '@/routes/admin/master/products';
import { Form, Head } from '@inertiajs/react'; import { Form, Head, router, usePage } from '@inertiajs/react';
import { import {
ArrowLeft, ArrowLeft,
Copy, Copy,
@ -18,7 +21,7 @@ import {
Plus, Plus,
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
type Category = { type Category = {
id: number; id: number;
@ -87,7 +90,20 @@ type VariantState = {
}; };
export default function ProductEdit({ product, categories }: Props) { 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) => ({ (v) => ({
id: v.id, id: v.id,
name: v.name, 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[]>( 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< const [sharedPrices, setSharedPrices] = useState<
Array<{ type: string; price: number }> Array<{ type: string; price: number }>
>( >(
initialVariants.length > 0 draft?.sharedPrices ??
? initialVariants[0].prices (serverVariants.length > 0
: createEmptyPrices(), ? serverVariants[0].prices
: createEmptyPrices()),
); );
const [variants, setVariants] = useState<VariantState[]>( const [variants, setVariants] = useState<VariantState[]>(() => {
initialVariants.length > 0 if (draft?.variants && draft.variants.length > 0) {
? initialVariants 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, id: null,
@ -134,12 +173,30 @@ export default function ProductEdit({ product, categories }: Props) {
uploading: false, uploading: false,
prices: createEmptyPrices(), prices: createEmptyPrices(),
}, },
], ];
); });
const variantsRef = useRef(variants); const variantsRef = useRef(variants);
variantsRef.current = 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(() => { const addVariant = useCallback(() => {
setVariants((prev) => [ setVariants((prev) => [
...prev, ...prev,
@ -252,6 +309,9 @@ export default function ProductEdit({ product, categories }: Props) {
function getPayload() { function getPayload() {
return { return {
name: productName,
status,
description,
category_ids: categoryIds, category_ids: categoryIds,
use_same_price: useSamePrice, use_same_price: useSamePrice,
shared_prices: useSamePrice shared_prices: useSamePrice
@ -320,7 +380,12 @@ export default function ProductEdit({ product, categories }: Props) {
<Input <Input
id="name" id="name"
name="name" name="name"
defaultValue={product.name} value={productName}
onChange={(e) =>
setProductName(
e.target.value,
)
}
placeholder="Masukkan nama produk" placeholder="Masukkan nama produk"
/> />
<InputError message={errors.name} /> <InputError message={errors.name} />
@ -334,7 +399,8 @@ export default function ProductEdit({ product, categories }: Props) {
</Label> </Label>
<RadioGroup <RadioGroup
name="status" name="status"
defaultValue={product.status} value={status}
onValueChange={setStatus}
className="flex gap-4" className="flex gap-4"
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -435,8 +501,11 @@ export default function ProductEdit({ product, categories }: Props) {
<Textarea <Textarea
id="description" id="description"
name="description" name="description"
defaultValue={ value={description}
product.description ?? '' onChange={(e) =>
setDescription(
e.target.value,
)
} }
placeholder="Masukkan deskripsi produk" placeholder="Masukkan deskripsi produk"
rows={3} rows={3}
@ -755,13 +824,24 @@ export default function ProductEdit({ product, categories }: Props) {
value={ value={
variant.photo variant.photo
} }
onChange={(photo) => onChange={(
photo,
) => {
updateVariant( updateVariant(
variantIndex, variantIndex,
'photo', 'photo',
photo, photo,
) );
} updateVariant(
variantIndex,
'photoUrl',
photo
? getTemporaryUrl(
photo,
)
: null,
);
}}
folder="product-variant" folder="product-variant"
existingUrl={ existingUrl={
variant.photoUrl variant.photoUrl