diff --git a/admin-spa/src/routes/Products/components/FilterBottomSheet.tsx b/admin-spa/src/routes/Products/components/FilterBottomSheet.tsx new file mode 100644 index 0000000..8b04677 --- /dev/null +++ b/admin-spa/src/routes/Products/components/FilterBottomSheet.tsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { X } from 'lucide-react'; +import { __ } from '@/lib/i18n'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import OrderBy from '@/components/filters/OrderBy'; + +interface FilterBottomSheetProps { + open: boolean; + onClose: () => void; + filters: { + status?: string; + type?: string; + stock_status?: string; + category?: string; + orderby: 'date' | 'title' | 'id' | 'modified'; + order: 'asc' | 'desc'; + }; + onFiltersChange: (filters: any) => void; + onReset: () => void; + activeFiltersCount: number; + categories?: Array<{ id: number; name: string }>; +} + +export function FilterBottomSheet({ + open, + onClose, + filters, + onFiltersChange, + onReset, + activeFiltersCount, + categories = [], +}: FilterBottomSheetProps) { + if (!open) return null; + + const hasActiveFilters = activeFiltersCount > 0; + + return ( + <> + {/* Backdrop */} +
+ + {/* Bottom Sheet */} +
+ {/* Drag Handle */} +
+
+
+ + {/* Header */} +
+

{__('Filters')}

+ +
+ + {/* Content */} +
+ {/* Status Filter */} +
+ + +
+ + {/* Product Type Filter */} +
+ + +
+ + {/* Stock Status Filter */} +
+ + +
+ + {/* Category Filter */} + {categories.length > 0 && ( +
+ + +
+ )} + + {/* Sort Order Filter */} +
+ + { + onFiltersChange({ + ...filters, + orderby: (v.orderby ?? 'date') as 'date' | 'title' | 'id' | 'modified', + order: (v.order ?? 'desc') as 'asc' | 'desc', + }); + }} + /> +
+
+ + {/* Footer - Only show Reset if filters active */} + {hasActiveFilters && ( +
+ +
+ )} +
+ + ); +} diff --git a/admin-spa/src/routes/Products/components/ProductCard.tsx b/admin-spa/src/routes/Products/components/ProductCard.tsx new file mode 100644 index 0000000..11a0b71 --- /dev/null +++ b/admin-spa/src/routes/Products/components/ProductCard.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { ChevronRight, Package } from 'lucide-react'; +import { __ } from '@/lib/i18n'; +import { Checkbox } from '@/components/ui/checkbox'; + +const stockStatusStyle: Record = { + instock: { bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-800 dark:text-emerald-300' }, + outofstock: { bg: 'bg-rose-100 dark:bg-rose-900/30', text: 'text-rose-800 dark:text-rose-300' }, + onbackorder: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-800 dark:text-amber-300' }, +}; + +const typeStyle: Record = { + simple: __('Simple'), + variable: __('Variable'), + grouped: __('Grouped'), + external: __('External'), +}; + +interface ProductCardProps { + product: any; + selected?: boolean; + onSelect?: (id: number) => void; +} + +export function ProductCard({ product, selected, onSelect }: ProductCardProps) { + const stockStatus = product.stock_status?.toLowerCase() || 'instock'; + const stockColors = stockStatusStyle[stockStatus] || stockStatusStyle.instock; + + return ( + +
+ {/* Checkbox */} + {onSelect && ( +
{ + e.preventDefault(); + e.stopPropagation(); + onSelect(product.id); + }} + > + +
+ )} + + {/* Product Image or Icon */} +
+ {product.image_url ? ( + {product.name} + ) : ( +
+ +
+ )} +
+ + {/* Content */} +
+ {/* Product Name */} +

+ {product.name} +

+ + {/* SKU & Type */} +
+ {product.sku && ( + {product.sku} + )} + {product.sku && product.type && ยท} + {product.type && ( + {typeStyle[product.type] || product.type} + )} +
+ + {/* Price & Stock */} +
+ {/* Price */} +
+ + {/* Stock Status Badge */} + + {product.stock_status === 'instock' && __('In Stock')} + {product.stock_status === 'outofstock' && __('Out of Stock')} + {product.stock_status === 'onbackorder' && __('On Backorder')} + {product.manage_stock && product.stock_quantity !== null && ( + ({product.stock_quantity}) + )} + +
+
+ + {/* Chevron */} + +
+ + ); +} diff --git a/admin-spa/src/routes/Products/components/SearchBar.tsx b/admin-spa/src/routes/Products/components/SearchBar.tsx new file mode 100644 index 0000000..94d72e7 --- /dev/null +++ b/admin-spa/src/routes/Products/components/SearchBar.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Search, SlidersHorizontal } from 'lucide-react'; +import { __ } from '@/lib/i18n'; + +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + onFilterClick: () => void; + filterCount?: number; +} + +export function SearchBar({ value, onChange, onFilterClick, filterCount = 0 }: SearchBarProps) { + return ( +
+ {/* Search Input */} +
+ + onChange(e.target.value)} + placeholder={__('Search products...')} + className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + /> +
+ + {/* Filter Button */} + +
+ ); +} diff --git a/admin-spa/src/routes/Products/index.tsx b/admin-spa/src/routes/Products/index.tsx index dacd92e..5f92ea9 100644 --- a/admin-spa/src/routes/Products/index.tsx +++ b/admin-spa/src/routes/Products/index.tsx @@ -1,11 +1,474 @@ -import React from 'react'; +import React, { useState, useCallback } from 'react'; +import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'; +import { api } from '@/lib/api'; +import { Filter, Package, Trash2, RefreshCw } from 'lucide-react'; +import { ErrorCard } from '@/components/ErrorCard'; +import { getPageLoadErrorMessage } from '@/lib/errorHandling'; import { __ } from '@/lib/i18n'; +import { useFABConfig } from '@/hooks/useFABConfig'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { toast } from 'sonner'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Link, useNavigate } from 'react-router-dom'; +import { Skeleton } from '@/components/ui/skeleton'; +import { setQuery, getQuery } from '@/lib/query-params'; +import { ProductCard } from './components/ProductCard'; +import { FilterBottomSheet } from './components/FilterBottomSheet'; +import { SearchBar } from './components/SearchBar'; -export default function ProductsIndex() { +const stockStatusStyle: Record = { + instock: 'bg-emerald-100 text-emerald-800', + outofstock: 'bg-rose-100 text-rose-800', + onbackorder: 'bg-amber-100 text-amber-800', +}; + +function StockBadge({ value, quantity }: { value?: string; quantity?: number }) { + const v = (value || '').toLowerCase(); + const cls = stockStatusStyle[v] || 'bg-slate-100 text-slate-800'; + const label = v === 'instock' ? __('In Stock') : v === 'outofstock' ? __('Out of Stock') : v === 'onbackorder' ? __('On Backorder') : v; + return ( -
-

{__('Products')}

-

{__('Coming soon โ€” SPA product list.')}

+ + {label} + {quantity !== null && quantity !== undefined && ` (${quantity})`} + + ); +} + +export default function Products() { + useFABConfig('products'); // Add FAB for creating products + const initial = getQuery(); + const [page, setPage] = useState(Number(initial.page ?? 1) || 1); + const [status, setStatus] = useState(initial.status || undefined); + const [type, setType] = useState(initial.type || undefined); + const [stockStatus, setStockStatus] = useState(initial.stock_status || undefined); + const [category, setCategory] = useState(initial.category || undefined); + const [orderby, setOrderby] = useState<'date'|'title'|'id'|'modified'>((initial.orderby as any) || 'date'); + const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc'); + const [selectedIds, setSelectedIds] = useState([]); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [filterSheetOpen, setFilterSheetOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [isRefreshing, setIsRefreshing] = useState(false); + const perPage = 20; + const queryClient = useQueryClient(); + + React.useEffect(() => { + setQuery({ page, status, type, stock_status: stockStatus, category, orderby, order }); + }, [page, status, type, stockStatus, category, orderby, order]); + + const q = useQuery({ + queryKey: ['products', { page, perPage, status, type, stock_status: stockStatus, category, orderby, order }], + queryFn: () => api.get('/products', { + page, per_page: perPage, + status, + type, + stock_status: stockStatus, + category, + orderby, + order, + }), + placeholderData: keepPreviousData, + }); + + // Fetch categories for filter + const categoriesQ = useQuery({ + queryKey: ['product-categories'], + queryFn: () => api.get('/products/categories'), + }); + + const data = q.data as undefined | { rows: any[]; total: number; page: number; per_page: number }; + const nav = useNavigate(); + + // Pull to refresh + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + await q.refetch(); + setTimeout(() => setIsRefreshing(false), 500); + }, [q]); + + // Filter products by search query + const filteredProducts = React.useMemo(() => { + const rows = data?.rows; + if (!rows) return []; + if (!searchQuery.trim()) return rows; + + const query = searchQuery.toLowerCase(); + return rows.filter((product: any) => + product.name?.toLowerCase().includes(query) || + product.sku?.toLowerCase().includes(query) || + product.id?.toString().includes(query) + ); + }, [data, searchQuery]); + + // Count active filters + const activeFiltersCount = React.useMemo(() => { + let count = 0; + if (status) count++; + if (type) count++; + if (stockStatus) count++; + if (category) count++; + if (orderby !== 'date' || order !== 'desc') count++; + return count; + }, [status, type, stockStatus, category, orderby, order]); + + // Bulk delete mutation + const deleteMutation = useMutation({ + mutationFn: async (ids: number[]) => { + const results = await Promise.allSettled( + ids.map(id => api.del(`/products/${id}`)) + ); + const failed = results.filter(r => r.status === 'rejected').length; + return { total: ids.length, failed }; + }, + onSuccess: (result) => { + const { total, failed } = result; + if (failed === 0) { + toast.success(__('Products deleted successfully')); + } else if (failed < total) { + toast.warning(__(`${total - failed} products deleted, ${failed} failed`)); + } else { + toast.error(__('Failed to delete products')); + } + setSelectedIds([]); + setShowDeleteDialog(false); + q.refetch(); + }, + onError: () => { + toast.error(__('Failed to delete products')); + setShowDeleteDialog(false); + }, + }); + + // Checkbox handlers + const allIds = filteredProducts.map(r => r.id) || []; + const allSelected = allIds.length > 0 && selectedIds.length === allIds.length; + const someSelected = selectedIds.length > 0 && selectedIds.length < allIds.length; + + const toggleAll = () => { + if (allSelected) { + setSelectedIds([]); + } else { + setSelectedIds(allIds); + } + }; + + const toggleRow = (id: number) => { + setSelectedIds(prev => + prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id] + ); + }; + + const handleDeleteClick = () => { + if (selectedIds.length > 0) { + setShowDeleteDialog(true); + } + }; + + const confirmDelete = () => { + deleteMutation.mutate(selectedIds); + }; + + const handleFiltersChange = (newFilters: any) => { + setPage(1); + setStatus(newFilters.status); + setType(newFilters.type); + setStockStatus(newFilters.stock_status); + setCategory(newFilters.category); + setOrderby(newFilters.orderby); + setOrder(newFilters.order); + }; + + const handleResetFilters = () => { + setStatus(undefined); + setType(undefined); + setStockStatus(undefined); + setCategory(undefined); + setOrderby('date'); + setOrder('desc'); + setPage(1); + }; + + return ( +
+ {/* Mobile: Search + Filter */} +
+ setFilterSheetOpen(true)} + filterCount={activeFiltersCount} + /> +
+ + {/* Desktop: Top Bar with Filters */} +
+
+
+ + + +
+ +
+ {/* Status Filter */} + + + {/* Type Filter */} + + + {/* Stock Status Filter */} + + + {activeFiltersCount > 0 && ( + + )} +
+
+
+ + {/* Error State */} + {q.isError && ( + q.refetch()} + /> + )} + + {/* Loading State */} + {q.isLoading && ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ )} + + {/* Desktop: Table */} + {!q.isLoading && !q.isError && ( +
+ + + + + + + + + + + + + + {filteredProducts.length === 0 ? ( + + + + ) : ( + filteredProducts.map((product: any) => ( + + + + + + + + + )) + )} + +
+ el && (el.indeterminate = someSelected && !allSelected)} + onCheckedChange={toggleAll} + aria-label={__('Select all')} + /> + {__('Product')}{__('SKU')}{__('Stock')}{__('Price')}{__('Type')}{__('Actions')}
+ + {searchQuery ? __('No products found matching your search') : __('No products found')} +
+ toggleRow(product.id)} + aria-label={__('Select product')} + /> + +
+ {product.image_url ? ( + {product.name} + ) : ( +
+ +
+ )} + + {product.name} + +
+
{product.sku || 'โ€”'} + + + {product.type} + + {__('Edit')} + +
+
+ )} + + {/* Mobile: Cards */} + {!q.isLoading && !q.isError && ( +
+ {filteredProducts.length === 0 ? ( +
+ + {searchQuery ? __('No products found matching your search') : __('No products found')} +
+ ) : ( + filteredProducts.map((product: any) => ( + + )) + )} +
+ )} + + {/* Pagination */} + {data && data.total > perPage && ( +
+
+ {__('Showing')} {((page - 1) * perPage) + 1} - {Math.min(page * perPage, data.total)} {__('of')} {data.total} +
+
+ + +
+
+ )} + + {/* Delete Dialog */} + + + + {__('Delete products?')} + + {__('Are you sure you want to delete')} {selectedIds.length} {selectedIds.length === 1 ? __('product') : __('products')}? {__('This action cannot be undone.')} + + + + {__('Cancel')} + + {deleteMutation.isPending ? __('Deleting...') : __('Delete')} + + + + + + {/* Mobile Filter Sheet */} + setFilterSheetOpen(false)} + filters={{ status, type, stock_status: stockStatus, category, orderby, order }} + onFiltersChange={handleFiltersChange} + onReset={handleResetFilters} + activeFiltersCount={activeFiltersCount} + categories={categoriesQ.data || []} + />
); }