From 479293ed09a4d678839a1dacb293bf1ced61d3fe Mon Sep 17 00:00:00 2001 From: dwindown Date: Wed, 19 Nov 2025 20:36:26 +0700 Subject: [PATCH] feat: Product New/Edit pages with comprehensive form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- admin-spa/src/routes/Products/Edit.tsx | 109 ++++ admin-spa/src/routes/Products/New.tsx | 73 ++- .../routes/Products/partials/ProductForm.tsx | 611 ++++++++++++++++++ 3 files changed, 789 insertions(+), 4 deletions(-) create mode 100644 admin-spa/src/routes/Products/Edit.tsx create mode 100644 admin-spa/src/routes/Products/partials/ProductForm.tsx diff --git a/admin-spa/src/routes/Products/Edit.tsx b/admin-spa/src/routes/Products/Edit.tsx new file mode 100644 index 0000000..af9900f --- /dev/null +++ b/admin-spa/src/routes/Products/Edit.tsx @@ -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(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 = ( +
+ + +
+ ); + setPageHeader(__('Edit Product'), actions); + return () => clearPageHeader(); + }, [updateMutation.isPending, productQ.isLoading, setPageHeader, clearPageHeader, navigate]); + + // Loading state + if (productQ.isLoading) { + return ( +
+ + + +
+ ); + } + + // Error state + if (productQ.isError) { + return ( + productQ.refetch()} + /> + ); + } + + const product = productQ.data; + + return ( +
+ +
+ ); +} diff --git a/admin-spa/src/routes/Products/New.tsx b/admin-spa/src/routes/Products/New.tsx index a15aa92..386bcb5 100644 --- a/admin-spa/src/routes/Products/New.tsx +++ b/admin-spa/src/routes/Products/New.tsx @@ -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 { 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() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const formRef = useRef(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 = ( +
+ + +
+ ); + setPageHeader(__('New Product'), actions); + return () => clearPageHeader(); + }, [createMutation.isPending, setPageHeader, clearPageHeader, navigate]); + return ( -
-

{__('New Product')}

-

{__('Coming soon — SPA product create form.')}

+
+
); } diff --git a/admin-spa/src/routes/Products/partials/ProductForm.tsx b/admin-spa/src/routes/Products/partials/ProductForm.tsx new file mode 100644 index 0000000..5ab7ab3 --- /dev/null +++ b/admin-spa/src/routes/Products/partials/ProductForm.tsx @@ -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; + 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; + className?: string; + formRef?: React.RefObject; + 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(initial?.type || 'simple'); + const [status, setStatus] = useState(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(initial?.stock_status || 'instock'); + const [selectedCategories, setSelectedCategories] = useState(initial?.categories || []); + const [selectedTags, setSelectedTags] = useState(initial?.tags || []); + const [images, setImages] = useState(initial?.images || []); + const [attributes, setAttributes] = useState>( + initial?.attributes || [] + ); + const [variations, setVariations] = useState(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[] = []; + const generate = (index: number, current: Record) => { + 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 ( +
+
+ {/* Basic Information */} +
+

{__('Basic Information')}

+ + {/* Product Name */} +
+ + setName(e.target.value)} + placeholder={__('Enter product name')} + required + /> +
+ + {/* Type & Status */} +
+
+ + +
+ +
+ + +
+
+ + {/* Description */} +
+ +