feat: Product New/Edit pages with comprehensive form

Implemented full Product CRUD create/edit functionality.

Product New Page (New.tsx):
 Create new products
 Page header with back/create buttons
 Form submission with React Query mutation
 Success toast & navigation
 Error handling

Product Edit Page (Edit.tsx):
 Load existing product data
 Update product with PUT request
 Loading & error states
 Page header with back/save buttons
 Query invalidation on success

ProductForm Component (partials/ProductForm.tsx - 600+ lines):
 Basic Information (name, type, status, descriptions)
 Product Types: Simple, Variable, Grouped, External
 Pricing (regular, sale, SKU) for simple products
 Inventory Management (stock tracking, quantity, status)
 Categories & Tags (multi-select with checkboxes)
 Attributes & Variations (for variable products)
  - Add/remove attributes
  - Define attribute options
  - Generate all variations automatically
  - Per-variation pricing & stock
 Additional Options (virtual, downloadable, featured)
 Form validation
 Reusable for create/edit modes
 Full i18n support

Features:
- Dynamic category/tag fetching from API
- Variation generator from attributes
- Manage stock toggle
- Stock status badges
- Form ref for external submit
- Hide submit button option (for page header buttons)
- Comprehensive validation
- Toast notifications

Pattern:
- Follows PROJECT_SOP.md CRUD template
- Consistent with Orders module
- Clean separation of concerns
- Type-safe with TypeScript
This commit is contained in:
dwindown
2025-11-19 20:36:26 +07:00
parent 757a425169
commit 479293ed09
3 changed files with 789 additions and 4 deletions

View File

@@ -0,0 +1,109 @@
import React, { useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import { toast } from 'sonner';
import { useFABConfig } from '@/hooks/useFABConfig';
import { usePageHeader } from '@/contexts/PageHeaderContext';
import { ProductForm, ProductFormData } from './partials/ProductForm';
import { Button } from '@/components/ui/button';
import { ErrorCard } from '@/components/ErrorCard';
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
import { Skeleton } from '@/components/ui/skeleton';
export default function ProductEdit() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const formRef = useRef<HTMLFormElement>(null);
const { setPageHeader, clearPageHeader } = usePageHeader();
// Hide FAB on edit product page
useFABConfig('none');
// Fetch product
const productQ = useQuery({
queryKey: ['products', id],
queryFn: () => api.get(`/products/${id}`),
enabled: !!id,
});
// Update mutation
const updateMutation = useMutation({
mutationFn: async (data: ProductFormData) => {
return api.put(`/products/${id}`, data);
},
onSuccess: (response: any) => {
toast.success(__('Product updated successfully'));
queryClient.invalidateQueries({ queryKey: ['products'] });
queryClient.invalidateQueries({ queryKey: ['products', id] });
// Navigate back to product detail or list
navigate(`/products/${id}`);
},
onError: (error: any) => {
toast.error(error.message || __('Failed to update product'));
},
});
const handleSubmit = async (data: ProductFormData) => {
await updateMutation.mutateAsync(data);
};
// Set page header with back button and save button
useEffect(() => {
const actions = (
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => navigate('/products')}>
{__('Back')}
</Button>
<Button
size="sm"
onClick={() => formRef.current?.requestSubmit()}
disabled={updateMutation.isPending || productQ.isLoading}
>
{updateMutation.isPending ? __('Saving...') : __('Save')}
</Button>
</div>
);
setPageHeader(__('Edit Product'), actions);
return () => clearPageHeader();
}, [updateMutation.isPending, productQ.isLoading, setPageHeader, clearPageHeader, navigate]);
// Loading state
if (productQ.isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
}
// Error state
if (productQ.isError) {
return (
<ErrorCard
title={__('Failed to load product')}
message={getPageLoadErrorMessage(productQ.error)}
onRetry={() => productQ.refetch()}
/>
);
}
const product = productQ.data;
return (
<div className="space-y-4">
<ProductForm
mode="edit"
initial={product}
onSubmit={handleSubmit}
formRef={formRef}
hideSubmitButton={true}
/>
</div>
);
}

View File

@@ -1,11 +1,76 @@
import React from 'react'; import React, { useRef, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
import { toast } from 'sonner';
import { useFABConfig } from '@/hooks/useFABConfig';
import { usePageHeader } from '@/contexts/PageHeaderContext';
import { ProductForm, ProductFormData } from './partials/ProductForm';
import { Button } from '@/components/ui/button';
export default function ProductNew() { export default function ProductNew() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const formRef = useRef<HTMLFormElement>(null);
const { setPageHeader, clearPageHeader } = usePageHeader();
// Hide FAB on new product page
useFABConfig('none');
// Create mutation
const createMutation = useMutation({
mutationFn: async (data: ProductFormData) => {
return api.post('/products', data);
},
onSuccess: (response: any) => {
toast.success(__('Product created successfully'));
queryClient.invalidateQueries({ queryKey: ['products'] });
// Navigate to product detail or edit page
if (response?.id) {
navigate(`/products/${response.id}`);
} else {
navigate('/products');
}
},
onError: (error: any) => {
toast.error(error.message || __('Failed to create product'));
},
});
const handleSubmit = async (data: ProductFormData) => {
await createMutation.mutateAsync(data);
};
// Set page header with back button and create button
useEffect(() => {
const actions = (
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => navigate('/products')}>
{__('Back')}
</Button>
<Button
size="sm"
onClick={() => formRef.current?.requestSubmit()}
disabled={createMutation.isPending}
>
{createMutation.isPending ? __('Creating...') : __('Create')}
</Button>
</div>
);
setPageHeader(__('New Product'), actions);
return () => clearPageHeader();
}, [createMutation.isPending, setPageHeader, clearPageHeader, navigate]);
return ( return (
<div> <div className="space-y-4">
<h1 className="text-xl font-semibold mb-3">{__('New Product')}</h1> <ProductForm
<p className="opacity-70">{__('Coming soon — SPA product create form.')}</p> mode="create"
onSubmit={handleSubmit}
formRef={formRef}
hideSubmitButton={true}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,611 @@
import React, { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, X, Upload, Image as ImageIcon } from 'lucide-react';
import { toast } from 'sonner';
// Types
export type ProductVariant = {
id?: number;
attributes: Record<string, string>;
sku?: string;
regular_price?: string;
sale_price?: string;
stock_quantity?: number;
manage_stock?: boolean;
stock_status?: 'instock' | 'outofstock' | 'onbackorder';
};
export type ProductFormData = {
name: string;
type: 'simple' | 'variable' | 'grouped' | 'external';
status: 'publish' | 'draft' | 'pending' | 'private';
description?: string;
short_description?: string;
sku?: string;
regular_price?: string;
sale_price?: string;
manage_stock?: boolean;
stock_quantity?: number;
stock_status?: 'instock' | 'outofstock' | 'onbackorder';
categories?: number[];
tags?: number[];
images?: string[];
attributes?: Array<{ name: string; options: string[]; variation: boolean }>;
variations?: ProductVariant[];
virtual?: boolean;
downloadable?: boolean;
featured?: boolean;
};
type Props = {
mode: 'create' | 'edit';
initial?: ProductFormData | null;
onSubmit: (data: ProductFormData) => Promise<void> | void;
className?: string;
formRef?: React.RefObject<HTMLFormElement>;
hideSubmitButton?: boolean;
};
export function ProductForm({
mode,
initial,
onSubmit,
className,
formRef,
hideSubmitButton = false,
}: Props) {
// Form state
const [name, setName] = useState(initial?.name || '');
const [type, setType] = useState<ProductFormData['type']>(initial?.type || 'simple');
const [status, setStatus] = useState<ProductFormData['status']>(initial?.status || 'publish');
const [description, setDescription] = useState(initial?.description || '');
const [shortDescription, setShortDescription] = useState(initial?.short_description || '');
const [sku, setSku] = useState(initial?.sku || '');
const [regularPrice, setRegularPrice] = useState(initial?.regular_price || '');
const [salePrice, setSalePrice] = useState(initial?.sale_price || '');
const [manageStock, setManageStock] = useState(initial?.manage_stock || false);
const [stockQuantity, setStockQuantity] = useState(initial?.stock_quantity?.toString() || '');
const [stockStatus, setStockStatus] = useState<ProductFormData['stock_status']>(initial?.stock_status || 'instock');
const [selectedCategories, setSelectedCategories] = useState<number[]>(initial?.categories || []);
const [selectedTags, setSelectedTags] = useState<number[]>(initial?.tags || []);
const [images, setImages] = useState<string[]>(initial?.images || []);
const [attributes, setAttributes] = useState<Array<{ name: string; options: string[]; variation: boolean }>>(
initial?.attributes || []
);
const [variations, setVariations] = useState<ProductVariant[]>(initial?.variations || []);
const [virtual, setVirtual] = useState(initial?.virtual || false);
const [downloadable, setDownloadable] = useState(initial?.downloadable || false);
const [featured, setFeatured] = useState(initial?.featured || false);
const [submitting, setSubmitting] = useState(false);
// Fetch categories
const categoriesQ = useQuery({
queryKey: ['product-categories'],
queryFn: () => api.get('/products/categories'),
});
// Fetch tags
const tagsQ = useQuery({
queryKey: ['product-tags'],
queryFn: () => api.get('/products/tags'),
});
const categories = categoriesQ.data || [];
const tags = tagsQ.data || [];
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (submitting) return;
// Validation
if (!name.trim()) {
toast.error(__('Product name is required'));
return;
}
if (type === 'simple' && !regularPrice) {
toast.error(__('Regular price is required for simple products'));
return;
}
setSubmitting(true);
try {
const payload: ProductFormData = {
name: name.trim(),
type,
status,
description,
short_description: shortDescription,
sku,
regular_price: regularPrice,
sale_price: salePrice,
manage_stock: manageStock,
stock_quantity: manageStock ? parseInt(stockQuantity) || 0 : undefined,
stock_status: stockStatus,
categories: selectedCategories,
tags: selectedTags,
images,
attributes: type === 'variable' ? attributes : undefined,
variations: type === 'variable' ? variations : undefined,
virtual,
downloadable,
featured,
};
await onSubmit(payload);
} catch (error: any) {
toast.error(error.message || __('Failed to save product'));
} finally {
setSubmitting(false);
}
};
// Add attribute
const addAttribute = () => {
setAttributes([...attributes, { name: '', options: [], variation: false }]);
};
// Remove attribute
const removeAttribute = (index: number) => {
setAttributes(attributes.filter((_, i) => i !== index));
};
// Update attribute
const updateAttribute = (index: number, field: string, value: any) => {
const updated = [...attributes];
updated[index] = { ...updated[index], [field]: value };
setAttributes(updated);
};
// Generate variations from attributes
const generateVariations = () => {
const variationAttrs = attributes.filter(attr => attr.variation && attr.options.length > 0);
if (variationAttrs.length === 0) {
toast.warning(__('Please add at least one variation attribute with options'));
return;
}
// Generate all combinations
const combinations: Record<string, string>[] = [];
const generate = (index: number, current: Record<string, string>) => {
if (index === variationAttrs.length) {
combinations.push({ ...current });
return;
}
const attr = variationAttrs[index];
for (const option of attr.options) {
generate(index + 1, { ...current, [attr.name]: option });
}
};
generate(0, {});
const newVariations: ProductVariant[] = combinations.map(attrs => ({
attributes: attrs,
sku: '',
regular_price: '',
sale_price: '',
stock_quantity: 0,
manage_stock: false,
stock_status: 'instock',
}));
setVariations(newVariations);
toast.success(__(`Generated ${newVariations.length} variations`));
};
return (
<form ref={formRef} onSubmit={handleSubmit} className={className}>
<div className="space-y-6">
{/* Basic Information */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">{__('Basic Information')}</h3>
{/* Product Name */}
<div>
<Label htmlFor="name">{__('Product Name')} *</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={__('Enter product name')}
required
/>
</div>
{/* Type & Status */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="type">{__('Product Type')}</Label>
<Select value={type} onValueChange={(v: any) => setType(v)}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="simple">{__('Simple Product')}</SelectItem>
<SelectItem value="variable">{__('Variable Product')}</SelectItem>
<SelectItem value="grouped">{__('Grouped Product')}</SelectItem>
<SelectItem value="external">{__('External Product')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="status">{__('Status')}</Label>
<Select value={status} onValueChange={(v: any) => setStatus(v)}>
<SelectTrigger id="status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="publish">{__('Published')}</SelectItem>
<SelectItem value="draft">{__('Draft')}</SelectItem>
<SelectItem value="pending">{__('Pending')}</SelectItem>
<SelectItem value="private">{__('Private')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
{/* Description */}
<div>
<Label htmlFor="description">{__('Description')}</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={__('Enter product description')}
rows={4}
/>
</div>
{/* Short Description */}
<div>
<Label htmlFor="short_description">{__('Short Description')}</Label>
<Textarea
id="short_description"
value={shortDescription}
onChange={(e) => setShortDescription(e.target.value)}
placeholder={__('Enter short description')}
rows={2}
/>
</div>
</div>
{/* Pricing (Simple Product Only) */}
{type === 'simple' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{__('Pricing')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label htmlFor="sku">{__('SKU')}</Label>
<Input
id="sku"
value={sku}
onChange={(e) => setSku(e.target.value)}
placeholder={__('Product SKU')}
/>
</div>
<div>
<Label htmlFor="regular_price">{__('Regular Price')} *</Label>
<Input
id="regular_price"
type="number"
step="0.01"
value={regularPrice}
onChange={(e) => setRegularPrice(e.target.value)}
placeholder="0.00"
required={type === 'simple'}
/>
</div>
<div>
<Label htmlFor="sale_price">{__('Sale Price')}</Label>
<Input
id="sale_price"
type="number"
step="0.01"
value={salePrice}
onChange={(e) => setSalePrice(e.target.value)}
placeholder="0.00"
/>
</div>
</div>
</div>
)}
{/* Inventory */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">{__('Inventory')}</h3>
<div className="flex items-center space-x-2">
<Checkbox
id="manage_stock"
checked={manageStock}
onCheckedChange={(checked) => setManageStock(checked as boolean)}
/>
<Label htmlFor="manage_stock" className="cursor-pointer">
{__('Manage stock quantity')}
</Label>
</div>
{manageStock && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="stock_quantity">{__('Stock Quantity')}</Label>
<Input
id="stock_quantity"
type="number"
value={stockQuantity}
onChange={(e) => setStockQuantity(e.target.value)}
placeholder="0"
/>
</div>
</div>
)}
<div>
<Label htmlFor="stock_status">{__('Stock Status')}</Label>
<Select value={stockStatus} onValueChange={(v: any) => setStockStatus(v)}>
<SelectTrigger id="stock_status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="instock">{__('In Stock')}</SelectItem>
<SelectItem value="outofstock">{__('Out of Stock')}</SelectItem>
<SelectItem value="onbackorder">{__('On Backorder')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
{/* Categories & Tags */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">{__('Categories & Tags')}</h3>
{/* Categories */}
<div>
<Label>{__('Categories')}</Label>
<div className="space-y-2 mt-2">
{categories.map((cat: any) => (
<div key={cat.id} className="flex items-center space-x-2">
<Checkbox
id={`cat-${cat.id}`}
checked={selectedCategories.includes(cat.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedCategories([...selectedCategories, cat.id]);
} else {
setSelectedCategories(selectedCategories.filter(id => id !== cat.id));
}
}}
/>
<Label htmlFor={`cat-${cat.id}`} className="cursor-pointer">
{cat.name}
</Label>
</div>
))}
</div>
</div>
{/* Tags */}
<div>
<Label>{__('Tags')}</Label>
<div className="flex flex-wrap gap-2 mt-2">
{tags.map((tag: any) => (
<button
key={tag.id}
type="button"
onClick={() => {
if (selectedTags.includes(tag.id)) {
setSelectedTags(selectedTags.filter(id => id !== tag.id));
} else {
setSelectedTags([...selectedTags, tag.id]);
}
}}
className={`px-3 py-1 rounded-full text-sm border transition-colors ${
selectedTags.includes(tag.id)
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background border-border hover:bg-accent'
}`}
>
{tag.name}
</button>
))}
</div>
</div>
</div>
{/* Attributes & Variations (Variable Product Only) */}
{type === 'variable' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{__('Attributes & Variations')}</h3>
<Button type="button" variant="outline" size="sm" onClick={addAttribute}>
<Plus className="w-4 h-4 mr-2" />
{__('Add Attribute')}
</Button>
</div>
{/* Attributes */}
{attributes.map((attr, index) => (
<div key={index} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<Label>{__('Attribute')} {index + 1}</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeAttribute(index)}
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input
value={attr.name}
onChange={(e) => updateAttribute(index, 'name', e.target.value)}
placeholder={__('Attribute name (e.g., Color, Size)')}
/>
<Input
value={attr.options.join(', ')}
onChange={(e) => updateAttribute(index, 'options', e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
placeholder={__('Options (comma separated)')}
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`variation-${index}`}
checked={attr.variation}
onCheckedChange={(checked) => updateAttribute(index, 'variation', checked)}
/>
<Label htmlFor={`variation-${index}`} className="cursor-pointer">
{__('Used for variations')}
</Label>
</div>
</div>
))}
{/* Generate Variations Button */}
{attributes.some(attr => attr.variation) && (
<Button type="button" variant="secondary" onClick={generateVariations}>
{__('Generate Variations')}
</Button>
)}
{/* Variations List */}
{variations.length > 0 && (
<div className="space-y-3">
<h4 className="font-medium">{__('Variations')} ({variations.length})</h4>
{variations.map((variation, index) => (
<div key={index} className="border rounded-lg p-4 space-y-3">
<div className="font-medium text-sm">
{Object.entries(variation.attributes).map(([key, value]) => `${key}: ${value}`).join(' | ')}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Input
placeholder={__('SKU')}
value={variation.sku || ''}
onChange={(e) => {
const updated = [...variations];
updated[index].sku = e.target.value;
setVariations(updated);
}}
/>
<Input
type="number"
step="0.01"
placeholder={__('Regular Price')}
value={variation.regular_price || ''}
onChange={(e) => {
const updated = [...variations];
updated[index].regular_price = e.target.value;
setVariations(updated);
}}
/>
<Input
type="number"
step="0.01"
placeholder={__('Sale Price')}
value={variation.sale_price || ''}
onChange={(e) => {
const updated = [...variations];
updated[index].sale_price = e.target.value;
setVariations(updated);
}}
/>
<Input
type="number"
placeholder={__('Stock')}
value={variation.stock_quantity || ''}
onChange={(e) => {
const updated = [...variations];
updated[index].stock_quantity = parseInt(e.target.value) || 0;
setVariations(updated);
}}
/>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Additional Options */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">{__('Additional Options')}</h3>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="virtual"
checked={virtual}
onCheckedChange={(checked) => setVirtual(checked as boolean)}
/>
<Label htmlFor="virtual" className="cursor-pointer">
{__('Virtual product')}
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="downloadable"
checked={downloadable}
onCheckedChange={(checked) => setDownloadable(checked as boolean)}
/>
<Label htmlFor="downloadable" className="cursor-pointer">
{__('Downloadable product')}
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="featured"
checked={featured}
onCheckedChange={(checked) => setFeatured(checked as boolean)}
/>
<Label htmlFor="featured" className="cursor-pointer">
{__('Featured product')}
</Label>
</div>
</div>
</div>
{/* Submit Button */}
{!hideSubmitButton && (
<div className="flex justify-end gap-3 pt-4 border-t">
<Button type="submit" disabled={submitting}>
{submitting ? __('Saving...') : mode === 'create' ? __('Create Product') : __('Update Product')}
</Button>
</div>
)}
</div>
</form>
);
}