feat: implement product draft functionality for create and edit pages
This commit is contained in:
parent
0126321a6e
commit
841767d5b1
@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
68
resources/js/hooks/use-product-draft.ts
Normal file
68
resources/js/hooks/use-product-draft.ts
Normal 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]);
|
||||
}
|
||||
73
resources/js/lib/product-draft.ts
Normal file
73
resources/js/lib/product-draft.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user