Restore images, more formatting and filter improvements
This commit is contained in:
@@ -218,6 +218,9 @@ router.get('/', async (req, res) => {
|
|||||||
const [vendors] = await pool.query(
|
const [vendors] = await pool.query(
|
||||||
'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor'
|
'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor'
|
||||||
);
|
);
|
||||||
|
const [brands] = await pool.query(
|
||||||
|
'SELECT DISTINCT brand FROM products WHERE visible = true AND brand IS NOT NULL AND brand != "" ORDER BY brand'
|
||||||
|
);
|
||||||
|
|
||||||
// Main query with all fields
|
// Main query with all fields
|
||||||
const query = `
|
const query = `
|
||||||
@@ -300,7 +303,8 @@ router.get('/', async (req, res) => {
|
|||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
categories: categories.map(category => category.name),
|
categories: categories.map(category => category.name),
|
||||||
vendors: vendors.map(vendor => vendor.vendor)
|
vendors: vendors.map(vendor => vendor.vendor),
|
||||||
|
brands: brands.map(brand => brand.brand)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ import {
|
|||||||
CommandList,
|
CommandList,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { X, Plus } from "lucide-react";
|
import { X, Plus } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
|
||||||
type FilterValue = string | number | boolean;
|
type FilterValue = string | number | boolean;
|
||||||
|
|
||||||
@@ -133,6 +137,7 @@ const FILTER_OPTIONS: FilterOption[] = [
|
|||||||
interface ProductFiltersProps {
|
interface ProductFiltersProps {
|
||||||
categories: string[];
|
categories: string[];
|
||||||
vendors: string[];
|
vendors: string[];
|
||||||
|
brands: string[];
|
||||||
onFilterChange: (filters: Record<string, FilterValue>) => void;
|
onFilterChange: (filters: Record<string, FilterValue>) => void;
|
||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
activeFilters: Record<string, FilterValue>;
|
activeFilters: Record<string, FilterValue>;
|
||||||
@@ -141,6 +146,7 @@ interface ProductFiltersProps {
|
|||||||
export function ProductFilters({
|
export function ProductFilters({
|
||||||
categories,
|
categories,
|
||||||
vendors,
|
vendors,
|
||||||
|
brands,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
activeFilters,
|
activeFilters,
|
||||||
@@ -150,9 +156,15 @@ export function ProductFilters({
|
|||||||
const [inputValue, setInputValue] = React.useState("");
|
const [inputValue, setInputValue] = React.useState("");
|
||||||
const [searchValue, setSearchValue] = React.useState("");
|
const [searchValue, setSearchValue] = React.useState("");
|
||||||
|
|
||||||
// Handle escape key
|
// Handle keyboard shortcuts
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Command/Ctrl + K to toggle filter
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowCommand(prev => !prev);
|
||||||
|
}
|
||||||
|
// Escape to close or go back
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (selectedFilter) {
|
if (selectedFilter) {
|
||||||
setSelectedFilter(null);
|
setSelectedFilter(null);
|
||||||
@@ -164,11 +176,9 @@ export function ProductFilters({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (showCommand) {
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
}, [selectedFilter]);
|
||||||
}
|
|
||||||
}, [showCommand, selectedFilter]);
|
|
||||||
|
|
||||||
// Update filter options with dynamic data
|
// Update filter options with dynamic data
|
||||||
const filterOptions = React.useMemo(() => {
|
const filterOptions = React.useMemo(() => {
|
||||||
@@ -185,9 +195,15 @@ export function ProductFilters({
|
|||||||
options: vendors.map(vendor => ({ label: vendor, value: vendor }))
|
options: vendors.map(vendor => ({ label: vendor, value: vendor }))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (option.id === 'brand') {
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
options: brands.map(brand => ({ label: brand, value: brand }))
|
||||||
|
};
|
||||||
|
}
|
||||||
return option;
|
return option;
|
||||||
});
|
});
|
||||||
}, [categories, vendors]);
|
}, [categories, vendors, brands]);
|
||||||
|
|
||||||
// Filter options based on search
|
// Filter options based on search
|
||||||
const filteredOptions = React.useMemo(() => {
|
const filteredOptions = React.useMemo(() => {
|
||||||
@@ -263,20 +279,198 @@ export function ProductFilters({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Popover open={showCommand} onOpenChange={setShowCommand}>
|
||||||
variant="outline"
|
<PopoverTrigger asChild>
|
||||||
size="sm"
|
<Button
|
||||||
className="h-8 border-dashed"
|
variant="outline"
|
||||||
onClick={() => {
|
size="sm"
|
||||||
setShowCommand(true);
|
className="h-8 border-dashed"
|
||||||
setSelectedFilter(null);
|
>
|
||||||
setInputValue("");
|
<Plus
|
||||||
setSearchValue("");
|
className={cn(
|
||||||
}}
|
"mr-2 h-4 w-4 transition-transform duration-200",
|
||||||
>
|
showCommand && "rotate-[135deg]"
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
)}
|
||||||
Add Filter
|
/>
|
||||||
</Button>
|
{showCommand ? "Cancel" : "Add Filter"}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0 w-[520px]"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command
|
||||||
|
className="rounded-none border-0"
|
||||||
|
shouldFilter={false}
|
||||||
|
>
|
||||||
|
{!selectedFilter ? (
|
||||||
|
<>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search and select filters..."
|
||||||
|
value={searchValue}
|
||||||
|
onValueChange={setSearchValue}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No filters found.</CommandEmpty>
|
||||||
|
{Object.entries(
|
||||||
|
filteredOptions.reduce<Record<string, FilterOption[]>>((acc, filter) => {
|
||||||
|
if (!acc[filter.group]) acc[filter.group] = [];
|
||||||
|
acc[filter.group].push(filter);
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
).map(([group, filters]) => (
|
||||||
|
<React.Fragment key={group}>
|
||||||
|
<CommandGroup heading={group}>
|
||||||
|
{filters.map((filter) => (
|
||||||
|
<CommandItem
|
||||||
|
key={filter.id}
|
||||||
|
value={`${filter.id} ${filter.label}`}
|
||||||
|
onSelect={() => {
|
||||||
|
handleSelectFilter(filter);
|
||||||
|
if (filter.type !== 'select') {
|
||||||
|
setInputValue("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
activeFilters?.[filter.id] && "bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
{activeFilters?.[filter.id] && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandSeparator />
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</>
|
||||||
|
) : selectedFilter.type === 'select' ? (
|
||||||
|
<>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={`Select ${selectedFilter.label.toLowerCase()}...`}
|
||||||
|
value={inputValue}
|
||||||
|
onValueChange={setInputValue}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Backspace' && !inputValue) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedFilter(null);
|
||||||
|
} else {
|
||||||
|
handleKeyDown(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No options found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedFilter(null);
|
||||||
|
setInputValue("");
|
||||||
|
}}
|
||||||
|
className="cursor-pointer text-muted-foreground"
|
||||||
|
>
|
||||||
|
← Back to filters
|
||||||
|
</CommandItem>
|
||||||
|
<CommandSeparator />
|
||||||
|
{selectedFilter.options
|
||||||
|
?.filter(option =>
|
||||||
|
option.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||||
|
)
|
||||||
|
.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
onSelect={() => {
|
||||||
|
handleApplyFilter(option.value);
|
||||||
|
setShowCommand(false);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</CommandItem>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={`Enter ${selectedFilter.label.toLowerCase()}`}
|
||||||
|
value={inputValue}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (selectedFilter.type === 'number') {
|
||||||
|
if (/^\d*\.?\d*$/.test(value)) {
|
||||||
|
setInputValue(value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setInputValue(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Backspace' && !inputValue) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedFilter(null);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
if (selectedFilter.type === 'number') {
|
||||||
|
const numValue = parseFloat(inputValue);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
handleApplyFilter(numValue);
|
||||||
|
setShowCommand(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (inputValue.trim() !== '') {
|
||||||
|
handleApplyFilter(inputValue.trim());
|
||||||
|
setShowCommand(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedFilter(null);
|
||||||
|
setInputValue("");
|
||||||
|
}}
|
||||||
|
className="cursor-pointer text-muted-foreground"
|
||||||
|
>
|
||||||
|
← Back to filters
|
||||||
|
</CommandItem>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
if (selectedFilter.type === 'number') {
|
||||||
|
const numValue = parseFloat(inputValue);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
handleApplyFilter(numValue);
|
||||||
|
setShowCommand(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (inputValue.trim() !== '') {
|
||||||
|
handleApplyFilter(inputValue.trim());
|
||||||
|
setShowCommand(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
Apply filter: {inputValue}
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
{activeFiltersList.map((filter) => (
|
{activeFiltersList.map((filter) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={filter.id}
|
key={filter.id}
|
||||||
@@ -311,179 +505,6 @@ export function ProductFilters({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCommand && (
|
|
||||||
<Command
|
|
||||||
className="rounded-lg border"
|
|
||||||
shouldFilter={false}
|
|
||||||
>
|
|
||||||
{!selectedFilter ? (
|
|
||||||
<>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="Search and select filters..."
|
|
||||||
value={searchValue}
|
|
||||||
onValueChange={setSearchValue}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No filters found.</CommandEmpty>
|
|
||||||
{Object.entries(
|
|
||||||
filteredOptions.reduce<Record<string, FilterOption[]>>((acc, filter) => {
|
|
||||||
if (!acc[filter.group]) acc[filter.group] = [];
|
|
||||||
acc[filter.group].push(filter);
|
|
||||||
return acc;
|
|
||||||
}, {})
|
|
||||||
).map(([group, filters]) => (
|
|
||||||
<React.Fragment key={group}>
|
|
||||||
<CommandGroup heading={group}>
|
|
||||||
{filters.map((filter) => (
|
|
||||||
<CommandItem
|
|
||||||
key={filter.id}
|
|
||||||
value={`${filter.id} ${filter.label}`}
|
|
||||||
onSelect={() => {
|
|
||||||
handleSelectFilter(filter);
|
|
||||||
if (filter.type !== 'select') {
|
|
||||||
setInputValue("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer",
|
|
||||||
activeFilters?.[filter.id] && "bg-accent"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{filter.label}
|
|
||||||
{activeFilters?.[filter.id] && (
|
|
||||||
<span className="ml-auto text-xs text-muted-foreground">
|
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
<CommandSeparator />
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</CommandList>
|
|
||||||
</>
|
|
||||||
) : selectedFilter.type === 'select' ? (
|
|
||||||
<>
|
|
||||||
<CommandInput
|
|
||||||
placeholder={`Select ${selectedFilter.label.toLowerCase()}...`}
|
|
||||||
value={inputValue}
|
|
||||||
onValueChange={setInputValue}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Backspace' && !inputValue) {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedFilter(null);
|
|
||||||
} else {
|
|
||||||
handleKeyDown(e);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No options found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
<CommandItem
|
|
||||||
onSelect={() => {
|
|
||||||
setSelectedFilter(null);
|
|
||||||
setInputValue("");
|
|
||||||
}}
|
|
||||||
className="cursor-pointer text-muted-foreground"
|
|
||||||
>
|
|
||||||
← Back to filters
|
|
||||||
</CommandItem>
|
|
||||||
<CommandSeparator />
|
|
||||||
{selectedFilter.options
|
|
||||||
?.filter(option =>
|
|
||||||
option.label.toLowerCase().includes(inputValue.toLowerCase())
|
|
||||||
)
|
|
||||||
.map((option) => (
|
|
||||||
<CommandItem
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
onSelect={() => {
|
|
||||||
handleApplyFilter(option.value);
|
|
||||||
setShowCommand(false);
|
|
||||||
}}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</CommandItem>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CommandInput
|
|
||||||
placeholder={`Enter ${selectedFilter.label.toLowerCase()}`}
|
|
||||||
value={inputValue}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (selectedFilter.type === 'number') {
|
|
||||||
if (/^\d*\.?\d*$/.test(value)) {
|
|
||||||
setInputValue(value);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setInputValue(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Backspace' && !inputValue) {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedFilter(null);
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
if (selectedFilter.type === 'number') {
|
|
||||||
const numValue = parseFloat(inputValue);
|
|
||||||
if (!isNaN(numValue)) {
|
|
||||||
handleApplyFilter(numValue);
|
|
||||||
setShowCommand(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (inputValue.trim() !== '') {
|
|
||||||
handleApplyFilter(inputValue.trim());
|
|
||||||
setShowCommand(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandGroup>
|
|
||||||
<CommandItem
|
|
||||||
onSelect={() => {
|
|
||||||
setSelectedFilter(null);
|
|
||||||
setInputValue("");
|
|
||||||
}}
|
|
||||||
className="cursor-pointer text-muted-foreground"
|
|
||||||
>
|
|
||||||
← Back to filters
|
|
||||||
</CommandItem>
|
|
||||||
<CommandSeparator />
|
|
||||||
<CommandItem
|
|
||||||
onSelect={() => {
|
|
||||||
if (selectedFilter.type === 'number') {
|
|
||||||
const numValue = parseFloat(inputValue);
|
|
||||||
if (!isNaN(numValue)) {
|
|
||||||
handleApplyFilter(numValue);
|
|
||||||
setShowCommand(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (inputValue.trim() !== '') {
|
|
||||||
handleApplyFilter(inputValue.trim());
|
|
||||||
setShowCommand(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
Apply filter: {inputValue}
|
|
||||||
</CommandItem>
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Command>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -262,15 +262,17 @@ export function ProductTable({
|
|||||||
switch (column) {
|
switch (column) {
|
||||||
case 'image':
|
case 'image':
|
||||||
return product.image ? (
|
return product.image ? (
|
||||||
<img
|
<div className="flex items-center justify-center w-[60px]">
|
||||||
src={product.image}
|
<img
|
||||||
alt={product.title}
|
src={product.image}
|
||||||
className="h-12 w-12 object-contain bg-white rounded border"
|
alt={product.title}
|
||||||
/>
|
className="h-12 w-12 object-contain bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
case 'title':
|
case 'title':
|
||||||
return (
|
return (
|
||||||
<div className="min-w-[300px]">
|
<div className="min-w-[200px]">
|
||||||
<div className="font-medium">{product.title}</div>
|
<div className="font-medium">{product.title}</div>
|
||||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -297,6 +299,13 @@ export function ProductTable({
|
|||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
if (columnDef?.format && value !== undefined && value !== null) {
|
if (columnDef?.format && value !== undefined && value !== null) {
|
||||||
|
// For numeric formats (those using toFixed), ensure the value is a number
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const num = parseFloat(value);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
return columnDef.format(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
return columnDef.format(value);
|
return columnDef.format(value);
|
||||||
}
|
}
|
||||||
return value || '-';
|
return value || '-';
|
||||||
|
|||||||
@@ -1,58 +1,22 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
|
|
||||||
export function ProductTableSkeleton() {
|
export function ProductTableSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<div className="divide-y">
|
||||||
<TableHeader>
|
{Array.from({ length: 20 }).map((_, i) => (
|
||||||
<TableRow>
|
<div key={i} className="flex items-center p-4 gap-4">
|
||||||
<TableHead>Product</TableHead>
|
<Skeleton className="h-12 w-12 rounded bg-muted" />
|
||||||
<TableHead>SKU</TableHead>
|
<div className="flex-1 space-y-2">
|
||||||
<TableHead>Stock</TableHead>
|
<Skeleton className="h-4 w-[40%] bg-muted" />
|
||||||
<TableHead>Price</TableHead>
|
<Skeleton className="h-3 w-[25%] bg-muted" />
|
||||||
<TableHead>Regular Price</TableHead>
|
</div>
|
||||||
<TableHead>Cost</TableHead>
|
<Skeleton className="h-4 w-[10%] bg-muted" />
|
||||||
<TableHead>Vendor</TableHead>
|
<Skeleton className="h-4 w-[15%] bg-muted" />
|
||||||
<TableHead>Brand</TableHead>
|
<Skeleton className="h-4 w-[10%] bg-muted" />
|
||||||
<TableHead>Categories</TableHead>
|
</div>
|
||||||
<TableHead>Status</TableHead>
|
))}
|
||||||
</TableRow>
|
</div>
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{Array.from({ length: 20 }).map((_, i) => (
|
|
||||||
<TableRow key={i}>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="h-8 w-8 rounded-full" />
|
|
||||||
<Skeleton className="h-4 w-[200px]" />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="h-4 w-8" />
|
|
||||||
<Skeleton className="h-5 w-20 rounded-full" />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
|
||||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
|
||||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
|
||||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
|
||||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
|
||||||
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
|
|
||||||
<TableCell><Skeleton className="h-5 w-16 rounded-full" /></TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -78,14 +78,19 @@ interface Product {
|
|||||||
|
|
||||||
// Column definition interface
|
// Column definition interface
|
||||||
interface ColumnDef {
|
interface ColumnDef {
|
||||||
key: keyof Product;
|
key: keyof Product | 'image';
|
||||||
label: string;
|
label: string;
|
||||||
group: string;
|
group: string;
|
||||||
format?: (value: any) => string | number;
|
format?: (value: any) => string | number;
|
||||||
|
width?: string;
|
||||||
|
noLabel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define available columns with grouping
|
// Define available columns with grouping
|
||||||
const AVAILABLE_COLUMNS: ColumnDef[] = [
|
const AVAILABLE_COLUMNS: ColumnDef[] = [
|
||||||
|
// Image (special column)
|
||||||
|
{ key: 'image', label: 'Image', group: 'Basic Info', noLabel: true, width: 'w-[60px]' },
|
||||||
|
|
||||||
// Basic Info Group
|
// Basic Info Group
|
||||||
{ key: 'title', label: 'Title', group: 'Basic Info' },
|
{ key: 'title', label: 'Title', group: 'Basic Info' },
|
||||||
{ key: 'sku', label: 'SKU', group: 'Basic Info' },
|
{ key: 'sku', label: 'SKU', group: 'Basic Info' },
|
||||||
@@ -96,26 +101,26 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
|
|||||||
{ key: 'barcode', label: 'Barcode', group: 'Basic Info' },
|
{ key: 'barcode', label: 'Barcode', group: 'Basic Info' },
|
||||||
|
|
||||||
// Inventory Group
|
// Inventory Group
|
||||||
{ key: 'stock_quantity', label: 'Stock Quantity', group: 'Inventory' },
|
{ key: 'stock_quantity', label: 'Stock Quantity', group: 'Inventory', format: (v) => v?.toString() ?? '-' },
|
||||||
{ key: 'stock_status', label: 'Stock Status', group: 'Inventory' },
|
{ key: 'stock_status', label: 'Stock Status', group: 'Inventory' },
|
||||||
{ key: 'days_of_inventory', label: 'Days of Inventory', group: 'Inventory' },
|
{ key: 'days_of_inventory', label: 'Days of Inventory', group: 'Inventory', format: (v) => v?.toFixed(1) ?? '-' },
|
||||||
{ key: 'reorder_point', label: 'Reorder Point', group: 'Inventory' },
|
{ key: 'reorder_point', label: 'Reorder Point', group: 'Inventory', format: (v) => v?.toString() ?? '-' },
|
||||||
{ key: 'safety_stock', label: 'Safety Stock', group: 'Inventory' },
|
{ key: 'safety_stock', label: 'Safety Stock', group: 'Inventory', format: (v) => v?.toString() ?? '-' },
|
||||||
{ key: 'moq', label: 'MOQ', group: 'Inventory' },
|
{ key: 'moq', label: 'MOQ', group: 'Inventory', format: (v) => v?.toString() ?? '-' },
|
||||||
{ key: 'uom', label: 'UOM', group: 'Inventory' },
|
{ key: 'uom', label: 'UOM', group: 'Inventory', format: (v) => v?.toString() ?? '-' },
|
||||||
|
|
||||||
// Pricing Group
|
// Pricing Group
|
||||||
{ key: 'price', label: 'Price', group: 'Pricing', format: (v) => v.toFixed(2) },
|
{ key: 'price', label: 'Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'regular_price', label: 'Regular Price', group: 'Pricing', format: (v) => v.toFixed(2) },
|
{ key: 'regular_price', label: 'Regular Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'cost_price', label: 'Cost Price', group: 'Pricing', format: (v) => v.toFixed(2) },
|
{ key: 'cost_price', label: 'Cost Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'landing_cost_price', label: 'Landing Cost', group: 'Pricing', format: (v) => v.toFixed(2) },
|
{ key: 'landing_cost_price', label: 'Landing Cost', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
|
|
||||||
// Sales Metrics Group
|
// Sales Metrics Group
|
||||||
{ key: 'daily_sales_avg', label: 'Daily Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' },
|
{ key: 'daily_sales_avg', label: 'Daily Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'weekly_sales_avg', label: 'Weekly Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' },
|
{ key: 'weekly_sales_avg', label: 'Weekly Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'monthly_sales_avg', label: 'Monthly Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' },
|
{ key: 'monthly_sales_avg', label: 'Monthly Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'avg_quantity_per_order', label: 'Avg Qty per Order', group: 'Sales Metrics', format: (v) => v?.toFixed(1) ?? '-' },
|
{ key: 'avg_quantity_per_order', label: 'Avg Qty per Order', group: 'Sales Metrics', format: (v) => v?.toFixed(1) ?? '-' },
|
||||||
{ key: 'number_of_orders', label: 'Number of Orders', group: 'Sales Metrics' },
|
{ key: 'number_of_orders', label: 'Number of Orders', group: 'Sales Metrics', format: (v) => v?.toString() ?? '-' },
|
||||||
{ key: 'first_sale_date', label: 'First Sale', group: 'Sales Metrics' },
|
{ key: 'first_sale_date', label: 'First Sale', group: 'Sales Metrics' },
|
||||||
{ key: 'last_sale_date', label: 'Last Sale', group: 'Sales Metrics' },
|
{ key: 'last_sale_date', label: 'Last Sale', group: 'Sales Metrics' },
|
||||||
|
|
||||||
@@ -129,9 +134,9 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
|
|||||||
{ key: 'turnover_rate', label: 'Turnover Rate', group: 'Financial Metrics', format: (v) => v?.toFixed(2) ?? '-' },
|
{ key: 'turnover_rate', label: 'Turnover Rate', group: 'Financial Metrics', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
|
|
||||||
// Purchase & Lead Time Group
|
// Purchase & Lead Time Group
|
||||||
{ key: 'avg_lead_time_days', label: 'Avg Lead Time (Days)', group: 'Purchase & Lead Time' },
|
{ key: 'avg_lead_time_days', label: 'Avg Lead Time (Days)', group: 'Purchase & Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
|
||||||
{ key: 'current_lead_time', label: 'Current Lead Time', group: 'Purchase & Lead Time' },
|
{ key: 'current_lead_time', label: 'Current Lead Time', group: 'Purchase & Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
|
||||||
{ key: 'target_lead_time', label: 'Target Lead Time', group: 'Purchase & Lead Time' },
|
{ key: 'target_lead_time', label: 'Target Lead Time', group: 'Purchase & Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
|
||||||
{ key: 'lead_time_status', label: 'Lead Time Status', group: 'Purchase & Lead Time' },
|
{ key: 'lead_time_status', label: 'Lead Time Status', group: 'Purchase & Lead Time' },
|
||||||
{ key: 'last_purchase_date', label: 'Last Purchase', group: 'Purchase & Lead Time' },
|
{ key: 'last_purchase_date', label: 'Last Purchase', group: 'Purchase & Lead Time' },
|
||||||
{ key: 'last_received_date', label: 'Last Received', group: 'Purchase & Lead Time' },
|
{ key: 'last_received_date', label: 'Last Received', group: 'Purchase & Lead Time' },
|
||||||
@@ -141,7 +146,8 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Default visible columns
|
// Default visible columns
|
||||||
const DEFAULT_VISIBLE_COLUMNS: (keyof Product)[] = [
|
const DEFAULT_VISIBLE_COLUMNS: (keyof Product | 'image')[] = [
|
||||||
|
'image',
|
||||||
'title',
|
'title',
|
||||||
'sku',
|
'sku',
|
||||||
'stock_quantity',
|
'stock_quantity',
|
||||||
@@ -154,13 +160,15 @@ const DEFAULT_VISIBLE_COLUMNS: (keyof Product)[] = [
|
|||||||
|
|
||||||
export function Products() {
|
export function Products() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const tableRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [filters, setFilters] = useState<Record<string, string | number | boolean>>({});
|
const [filters, setFilters] = useState<Record<string, string | number | boolean>>({});
|
||||||
const [sortColumn, setSortColumn] = useState<keyof Product>('title');
|
const [sortColumn, setSortColumn] = useState<keyof Product>('title');
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [visibleColumns, setVisibleColumns] = useState<Set<keyof Product>>(new Set(DEFAULT_VISIBLE_COLUMNS));
|
const [visibleColumns, setVisibleColumns] = useState<Set<keyof Product | 'image'>>(new Set(DEFAULT_VISIBLE_COLUMNS));
|
||||||
const [columnOrder, setColumnOrder] = useState<(keyof Product)[]>(DEFAULT_VISIBLE_COLUMNS);
|
const [columnOrder, setColumnOrder] = useState<(keyof Product | 'image')[]>([
|
||||||
|
...DEFAULT_VISIBLE_COLUMNS,
|
||||||
|
...AVAILABLE_COLUMNS.map(col => col.key).filter(key => !DEFAULT_VISIBLE_COLUMNS.includes(key))
|
||||||
|
]);
|
||||||
|
|
||||||
// Group columns by their group property
|
// Group columns by their group property
|
||||||
const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => {
|
const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => {
|
||||||
@@ -172,7 +180,7 @@ export function Products() {
|
|||||||
}, {} as Record<string, ColumnDef[]>);
|
}, {} as Record<string, ColumnDef[]>);
|
||||||
|
|
||||||
// Toggle column visibility
|
// Toggle column visibility
|
||||||
const toggleColumn = (columnKey: keyof Product) => {
|
const toggleColumn = (columnKey: keyof Product | 'image') => {
|
||||||
setVisibleColumns(prev => {
|
setVisibleColumns(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(columnKey)) {
|
if (next.has(columnKey)) {
|
||||||
@@ -244,13 +252,6 @@ export function Products() {
|
|||||||
}
|
}
|
||||||
}, [page, data?.pagination, queryClient, filters, sortColumn, sortDirection]);
|
}, [page, data?.pagination, queryClient, filters, sortColumn, sortDirection]);
|
||||||
|
|
||||||
// Scroll to top when changing pages
|
|
||||||
useEffect(() => {
|
|
||||||
if (tableRef.current) {
|
|
||||||
tableRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}
|
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
const handleSort = (column: keyof Product) => {
|
const handleSort = (column: keyof Product) => {
|
||||||
if (sortColumn === column) {
|
if (sortColumn === column) {
|
||||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||||
@@ -272,21 +273,18 @@ export function Products() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
setPage(newPage);
|
setPage(newPage);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update column order when visibility changes
|
// Handle column reordering from drag and drop
|
||||||
useEffect(() => {
|
const handleColumnOrderChange = (newOrder: (keyof Product | 'image')[]) => {
|
||||||
setColumnOrder(prev => {
|
setColumnOrder(prev => {
|
||||||
const newOrder = prev.filter(col => visibleColumns.has(col));
|
// Keep hidden columns in their current positions
|
||||||
const newColumns = Array.from(visibleColumns).filter(col => !prev.includes(col));
|
const newOrderSet = new Set(newOrder);
|
||||||
return [...newOrder, ...newColumns];
|
const hiddenColumns = prev.filter(col => !newOrderSet.has(col));
|
||||||
|
return [...newOrder, ...hiddenColumns];
|
||||||
});
|
});
|
||||||
}, [visibleColumns]);
|
|
||||||
|
|
||||||
// Handle column reordering
|
|
||||||
const handleColumnOrderChange = (newOrder: (keyof Product)[]) => {
|
|
||||||
setColumnOrder(newOrder);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPagination = () => {
|
const renderPagination = () => {
|
||||||
@@ -376,72 +374,68 @@ export function Products() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8">
|
<div className="p-8 space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<h1 className="text-2xl font-bold">Products</h1>
|
||||||
<h1 className="text-2xl font-bold">Products</h1>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{data?.pagination.total.toLocaleString() ?? '...'} products
|
|
||||||
</div>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Settings2 className="mr-2 h-4 w-4" />
|
|
||||||
Columns
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-[280px]">
|
|
||||||
<DropdownMenuLabel>Toggle Columns</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{Object.entries(columnsByGroup).map(([group, columns]) => (
|
|
||||||
<div key={group}>
|
|
||||||
<DropdownMenuLabel className="text-xs font-bold text-muted-foreground">
|
|
||||||
{group}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{columns.map((col) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={col.key}
|
|
||||||
checked={visibleColumns.has(col.key)}
|
|
||||||
onCheckedChange={() => toggleColumn(col.key)}
|
|
||||||
>
|
|
||||||
{col.label}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref={tableRef}>
|
<div>
|
||||||
<ProductFilters
|
<div className="flex items-center justify-between mb-4">
|
||||||
categories={data?.filters?.categories ?? []}
|
<ProductFilters
|
||||||
vendors={data?.filters?.vendors ?? []}
|
categories={data?.filters?.categories ?? []}
|
||||||
onFilterChange={handleFilterChange}
|
vendors={data?.filters?.vendors ?? []}
|
||||||
onClearFilters={handleClearFilters}
|
brands={data?.filters?.brands ?? []}
|
||||||
activeFilters={filters}
|
onFilterChange={handleFilterChange}
|
||||||
/>
|
onClearFilters={handleClearFilters}
|
||||||
<div className="mt-6">
|
activeFilters={filters}
|
||||||
{isLoading ? (
|
/>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{data?.pagination.total && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{data.pagination.total.toLocaleString()} products
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Settings2 className="mr-2 h-4 w-4" />
|
||||||
|
Columns
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-[280px] max-h-[calc(100vh-4rem)] overflow-y-auto">
|
||||||
|
<DropdownMenuLabel>Toggle Columns</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{Object.entries(columnsByGroup).map(([group, columns]) => (
|
||||||
|
<div key={group}>
|
||||||
|
<DropdownMenuLabel className="text-xs font-bold text-muted-foreground">
|
||||||
|
{group}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={col.key}
|
||||||
|
checked={visibleColumns.has(col.key)}
|
||||||
|
onCheckedChange={() => toggleColumn(col.key)}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
{isLoading || isFetching ? (
|
||||||
<ProductTableSkeleton />
|
<ProductTableSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<div className="relative">
|
<ProductTable
|
||||||
{isFetching && (
|
products={data?.products ?? []}
|
||||||
<div className="absolute inset-0 bg-background/50 backdrop-blur-sm flex items-center justify-center z-50">
|
visibleColumns={visibleColumns}
|
||||||
<div className="text-muted-foreground">Loading...</div>
|
sortColumn={sortColumn}
|
||||||
</div>
|
sortDirection={sortDirection}
|
||||||
)}
|
columnDefs={AVAILABLE_COLUMNS}
|
||||||
<ProductTable
|
onSort={handleSort}
|
||||||
products={data?.products ?? []}
|
onColumnOrderChange={handleColumnOrderChange}
|
||||||
visibleColumns={new Set(columnOrder)}
|
/>
|
||||||
sortColumn={sortColumn}
|
|
||||||
sortDirection={sortDirection}
|
|
||||||
columnDefs={AVAILABLE_COLUMNS}
|
|
||||||
onSort={handleSort}
|
|
||||||
onColumnOrderChange={handleColumnOrderChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user