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:
dwindown
2025-11-20 15:03:31 +07:00
parent 7bbc098a8f
commit 3a4e68dadf
2 changed files with 149 additions and 0 deletions

View File

@@ -20,6 +20,7 @@ import ProductTags from '@/routes/Products/Tags';
import ProductAttributes from '@/routes/Products/Attributes'; import ProductAttributes from '@/routes/Products/Attributes';
import CouponsIndex from '@/routes/Coupons'; import CouponsIndex from '@/routes/Coupons';
import CouponNew from '@/routes/Coupons/New'; import CouponNew from '@/routes/Coupons/New';
import CouponEdit from '@/routes/Coupons/Edit';
import CustomersIndex from '@/routes/Customers'; import CustomersIndex from '@/routes/Customers';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Maximize2, Minimize2, Loader2 } from 'lucide-react'; import { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Maximize2, Minimize2, Loader2 } from 'lucide-react';
@@ -477,6 +478,7 @@ function AppRoutes() {
{/* Coupons */} {/* Coupons */}
<Route path="/coupons" element={<CouponsIndex />} /> <Route path="/coupons" element={<CouponsIndex />} />
<Route path="/coupons/new" element={<CouponNew />} /> <Route path="/coupons/new" element={<CouponNew />} />
<Route path="/coupons/:id" element={<CouponEdit />} />
{/* Customers */} {/* Customers */}
<Route path="/customers" element={<CustomersIndex />} /> <Route path="/customers" element={<CustomersIndex />} />

View 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>
);
}