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 { formatMoney, getStoreCurrency } from '@/lib/currency'; 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'; 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 ( {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, staleTime: 0, // Always fetch fresh data gcTime: 0, // Don't cache }); // 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}/edit`)) ); 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 */}
{selectedIds.length > 0 && ( )}
{/* 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) => ( )) )}
{ if (el) { (el as any).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.manage_stock ? ( ) : ( )} {product.price_html ? ( ) : product.sale_price ? ( {formatMoney(product.regular_price)} {formatMoney(product.sale_price)} ) : product.regular_price ? ( {formatMoney(product.regular_price)} ) : ( )} {product.type || 'simple'} {__('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 || []} />
); }