feat: Add coupon edit route and multiselect component
Fixed blank coupon edit page and added multiselect component 1. Fixed Missing Route: - Added CouponEdit import in App.tsx - Added route: /coupons/:id -> CouponEdit component - Edit page now loads correctly 2. Created MultiSelect Component: - Shadcn-based multiselect with search - Badge display for selected items - Click badge X to remove - Shows +N more when exceeds maxDisplay - Searchable dropdown with Command component - Keyboard accessible Features: - Selected items shown as badges - Remove item by clicking X on badge - Search/filter options - Checkbox indicators - Max display limit (default 3) - Responsive and accessible Next: Add product/category/brand selectors to coupon form
This commit is contained in:
@@ -20,6 +20,7 @@ import ProductTags from '@/routes/Products/Tags';
|
||||
import ProductAttributes from '@/routes/Products/Attributes';
|
||||
import CouponsIndex from '@/routes/Coupons';
|
||||
import CouponNew from '@/routes/Coupons/New';
|
||||
import CouponEdit from '@/routes/Coupons/Edit';
|
||||
import CustomersIndex from '@/routes/Customers';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Maximize2, Minimize2, Loader2 } from 'lucide-react';
|
||||
@@ -477,6 +478,7 @@ function AppRoutes() {
|
||||
{/* Coupons */}
|
||||
<Route path="/coupons" element={<CouponsIndex />} />
|
||||
<Route path="/coupons/new" element={<CouponNew />} />
|
||||
<Route path="/coupons/:id" element={<CouponEdit />} />
|
||||
|
||||
{/* Customers */}
|
||||
<Route path="/customers" element={<CustomersIndex />} />
|
||||
|
||||
147
admin-spa/src/components/ui/multi-select.tsx
Normal file
147
admin-spa/src/components/ui/multi-select.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import * as React from "react";
|
||||
import { X, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
export interface MultiSelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
options: MultiSelectOption[];
|
||||
selected: string[];
|
||||
onChange: (selected: string[]) => void;
|
||||
placeholder?: string;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
maxDisplay?: number;
|
||||
}
|
||||
|
||||
export function MultiSelect({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
placeholder = "Select items...",
|
||||
emptyMessage = "No items found.",
|
||||
className,
|
||||
maxDisplay = 3,
|
||||
}: MultiSelectProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const handleUnselect = (value: string) => {
|
||||
onChange(selected.filter((s) => s !== value));
|
||||
};
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (selected.includes(value)) {
|
||||
onChange(selected.filter((s) => s !== value));
|
||||
} else {
|
||||
onChange([...selected, value]);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedOptions = options.filter((option) =>
|
||||
selected.includes(option.value)
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"w-full justify-between h-auto min-h-10",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{selectedOptions.length === 0 && (
|
||||
<span className="text-muted-foreground">{placeholder}</span>
|
||||
)}
|
||||
{selectedOptions.slice(0, maxDisplay).map((option) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.value}
|
||||
className="mr-1 mb-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUnselect(option.value);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
<button
|
||||
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleUnselect(option.value);
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleUnselect(option.value);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
{selectedOptions.length > maxDisplay && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="mr-1 mb-1"
|
||||
>
|
||||
+{selectedOptions.length - maxDisplay} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search..." />
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
<CommandGroup className="max-h-64 overflow-auto">
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => handleSelect(option.value)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selected.includes(option.value)
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user