feat: implement AddCohort form with multi-select support and step-based navigation
This commit is contained in:
@@ -10,19 +10,19 @@ export interface CohartResponse {
|
||||
description: string;
|
||||
status: CohortStatus;
|
||||
// Passenger Attributes
|
||||
cabinClassId: string | null;
|
||||
passengerTypeId: string | null;
|
||||
ancillaryPurchaseId: string | null;
|
||||
cabinClasses: MasterDataOption[];
|
||||
passengerTypes: MasterDataOption[];
|
||||
ancillaryPurchases: MasterDataOption[];
|
||||
// Customer Value
|
||||
loyaltyTierId: string | null;
|
||||
revenueSegmentId: string | null;
|
||||
loyaltyTiers: MasterDataOption[];
|
||||
revenueSegments: MasterDataOption[];
|
||||
highValuePassenger: HighValuePassenger;
|
||||
// Journey Context
|
||||
flightType: FlightType | null;
|
||||
regionId: string | null;
|
||||
tripPurposeId: string | null;
|
||||
originAirport: string | null;
|
||||
destinationAirport: string | null;
|
||||
regions: MasterDataOption[];
|
||||
tripPurposes: MasterDataOption[];
|
||||
originAirport: string[];
|
||||
destinationAirport: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -44,19 +44,19 @@ export interface CreateCohartPayload {
|
||||
description?: string;
|
||||
status: CohortStatus;
|
||||
// Passenger Attributes
|
||||
cabinClassId?: string;
|
||||
passengerTypeId?: string;
|
||||
ancillaryPurchaseId?: string;
|
||||
cabinClassIds?: string[];
|
||||
passengerTypeIds?: string[];
|
||||
ancillaryPurchaseIds?: string[];
|
||||
// Customer Value
|
||||
loyaltyTierId?: string;
|
||||
revenueSegmentId?: string;
|
||||
loyaltyTierIds?: string[];
|
||||
revenueSegmentIds?: string[];
|
||||
highValuePassenger?: HighValuePassenger;
|
||||
// Journey Context
|
||||
flightType?: FlightType;
|
||||
regionId?: string;
|
||||
tripPurposeId?: string;
|
||||
originAirport?: string;
|
||||
destinationAirport?: string;
|
||||
regionIds?: string[];
|
||||
tripPurposeIds?: string[];
|
||||
originAirports?: string[];
|
||||
destinationAirports?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateCohartPayload extends Partial<CreateCohartPayload> {}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CustomModal,
|
||||
CustomInput,
|
||||
CustomDropdown,
|
||||
CustomMultiSelect,
|
||||
CustomTextArea,
|
||||
CustomButton,
|
||||
CustomStatus,
|
||||
@@ -57,17 +58,17 @@ interface StepOneData {
|
||||
}
|
||||
|
||||
interface StepTwoData {
|
||||
cabinClassId: string;
|
||||
passengerTypeId: string;
|
||||
ancillaryPurchaseId: string;
|
||||
loyaltyTierId: string;
|
||||
revenueSegmentId: string;
|
||||
cabinClassIds: string[];
|
||||
passengerTypeIds: string[];
|
||||
ancillaryPurchaseIds: string[];
|
||||
loyaltyTierIds: string[];
|
||||
revenueSegmentIds: string[];
|
||||
highValuePassenger: HighValuePassenger;
|
||||
flightType: string;
|
||||
regionId: string;
|
||||
tripPurposeId: string;
|
||||
originAirport: string;
|
||||
destinationAirport: string;
|
||||
regionIds: string[];
|
||||
tripPurposeIds: string[];
|
||||
originAirports: string;
|
||||
destinationAirports: string;
|
||||
}
|
||||
|
||||
interface AddCohartProps {
|
||||
@@ -117,17 +118,17 @@ function toDropdownOptions(items: MasterDataItem[]) {
|
||||
|
||||
const EMPTY_STEP_ONE: StepOneData = { name: '', status: '', description: '' };
|
||||
const EMPTY_STEP_TWO: StepTwoData = {
|
||||
cabinClassId: '',
|
||||
passengerTypeId: '',
|
||||
ancillaryPurchaseId: '',
|
||||
loyaltyTierId: '',
|
||||
revenueSegmentId: '',
|
||||
cabinClassIds: [],
|
||||
passengerTypeIds: [],
|
||||
ancillaryPurchaseIds: [],
|
||||
loyaltyTierIds: [],
|
||||
revenueSegmentIds: [],
|
||||
highValuePassenger: 'Any',
|
||||
flightType: '',
|
||||
regionId: '',
|
||||
tripPurposeId: '',
|
||||
originAirport: '',
|
||||
destinationAirport: '',
|
||||
regionIds: [],
|
||||
tripPurposeIds: [],
|
||||
originAirports: '',
|
||||
destinationAirports: '',
|
||||
};
|
||||
|
||||
export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddCohartProps) {
|
||||
@@ -188,17 +189,17 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
description: editData.description || '',
|
||||
});
|
||||
setStepTwo({
|
||||
cabinClassId: editData.cabinClassId || '',
|
||||
passengerTypeId: editData.passengerTypeId || '',
|
||||
ancillaryPurchaseId: editData.ancillaryPurchaseId || '',
|
||||
loyaltyTierId: editData.loyaltyTierId || '',
|
||||
revenueSegmentId: editData.revenueSegmentId || '',
|
||||
cabinClassIds: editData.cabinClasses?.map(c => c.id) || [],
|
||||
passengerTypeIds: editData.passengerTypes?.map(c => c.id) || [],
|
||||
ancillaryPurchaseIds: editData.ancillaryPurchases?.map(c => c.id) || [],
|
||||
loyaltyTierIds: editData.loyaltyTiers?.map(c => c.id) || [],
|
||||
revenueSegmentIds: editData.revenueSegments?.map(c => c.id) || [],
|
||||
highValuePassenger: editData.highValuePassenger || 'Any',
|
||||
flightType: editData.flightType || '',
|
||||
regionId: editData.regionId || '',
|
||||
tripPurposeId: editData.tripPurposeId || '',
|
||||
originAirport: editData.originAirport || '',
|
||||
destinationAirport: editData.destinationAirport || '',
|
||||
regionIds: editData.regions?.map(c => c.id) || [],
|
||||
tripPurposeIds: editData.tripPurposes?.map(c => c.id) || [],
|
||||
originAirports: editData.originAirport?.join(', ') || '',
|
||||
destinationAirports: editData.destinationAirport?.join(', ') || '',
|
||||
});
|
||||
} else if (isOpen && !editData) {
|
||||
setStepOne(EMPTY_STEP_ONE);
|
||||
@@ -223,17 +224,17 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
name: stepOne.name.trim(),
|
||||
description: stepOne.description.trim() || undefined,
|
||||
status: stepOne.status as CohortStatus,
|
||||
cabinClassId: stepTwo.cabinClassId || undefined,
|
||||
passengerTypeId: stepTwo.passengerTypeId || undefined,
|
||||
ancillaryPurchaseId: stepTwo.ancillaryPurchaseId || undefined,
|
||||
loyaltyTierId: stepTwo.loyaltyTierId || undefined,
|
||||
revenueSegmentId: stepTwo.revenueSegmentId || undefined,
|
||||
cabinClassIds: stepTwo.cabinClassIds.length ? stepTwo.cabinClassIds : undefined,
|
||||
passengerTypeIds: stepTwo.passengerTypeIds.length ? stepTwo.passengerTypeIds : undefined,
|
||||
ancillaryPurchaseIds: stepTwo.ancillaryPurchaseIds.length ? stepTwo.ancillaryPurchaseIds : undefined,
|
||||
loyaltyTierIds: stepTwo.loyaltyTierIds.length ? stepTwo.loyaltyTierIds : undefined,
|
||||
revenueSegmentIds: stepTwo.revenueSegmentIds.length ? stepTwo.revenueSegmentIds : undefined,
|
||||
highValuePassenger: stepTwo.highValuePassenger || undefined,
|
||||
flightType: stepTwo.flightType as any || undefined,
|
||||
regionId: stepTwo.regionId || undefined,
|
||||
tripPurposeId: stepTwo.tripPurposeId || undefined,
|
||||
originAirport: stepTwo.originAirport.trim() || undefined,
|
||||
destinationAirport: stepTwo.destinationAirport.trim() || undefined,
|
||||
regionIds: stepTwo.regionIds.length ? stepTwo.regionIds : undefined,
|
||||
tripPurposeIds: stepTwo.tripPurposeIds.length ? stepTwo.tripPurposeIds : undefined,
|
||||
originAirports: stepTwo.originAirports.trim() ? stepTwo.originAirports.split(',').map(s => s.trim()).filter(Boolean) : undefined,
|
||||
destinationAirports: stepTwo.destinationAirports.trim() ? stepTwo.destinationAirports.split(',').map(s => s.trim()).filter(Boolean) : undefined,
|
||||
};
|
||||
|
||||
if (isEditMode && editData) {
|
||||
@@ -255,16 +256,16 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
};
|
||||
|
||||
const passengerHasData =
|
||||
stepTwo.cabinClassId !== '' || stepTwo.passengerTypeId !== '' || stepTwo.ancillaryPurchaseId !== '';
|
||||
stepTwo.cabinClassIds.length > 0 || stepTwo.passengerTypeIds.length > 0 || stepTwo.ancillaryPurchaseIds.length > 0;
|
||||
|
||||
const customerHasData = stepTwo.loyaltyTierId !== '' || stepTwo.revenueSegmentId !== '';
|
||||
const customerHasData = stepTwo.loyaltyTierIds.length > 0 || stepTwo.revenueSegmentIds.length > 0;
|
||||
|
||||
const journeyHasData =
|
||||
stepTwo.flightType !== '' ||
|
||||
stepTwo.regionId !== '' ||
|
||||
stepTwo.tripPurposeId !== '' ||
|
||||
stepTwo.originAirport !== '' ||
|
||||
stepTwo.destinationAirport !== '';
|
||||
stepTwo.regionIds.length > 0 ||
|
||||
stepTwo.tripPurposeIds.length > 0 ||
|
||||
stepTwo.originAirports !== '' ||
|
||||
stepTwo.destinationAirports !== '';
|
||||
|
||||
// ─── Stepper ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -397,28 +398,28 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
onToggle={() => toggleSection('passenger')}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<CustomDropdown
|
||||
<CustomMultiSelect
|
||||
label="Cabin Class"
|
||||
options={toDropdownOptions(cabinClasses)}
|
||||
value={stepTwo.cabinClassId}
|
||||
onChange={val => setStepTwo({ ...stepTwo, cabinClassId: val })}
|
||||
placeholder="Selected Option"
|
||||
value={stepTwo.cabinClassIds}
|
||||
onChange={val => setStepTwo({ ...stepTwo, cabinClassIds: val as string[] })}
|
||||
placeholder="Select Cabin Classes"
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
<CustomDropdown
|
||||
<CustomMultiSelect
|
||||
label="Passenger Type"
|
||||
options={toDropdownOptions(passengerTypes)}
|
||||
value={stepTwo.passengerTypeId}
|
||||
onChange={val => setStepTwo({ ...stepTwo, passengerTypeId: val })}
|
||||
placeholder="Selected Option"
|
||||
value={stepTwo.passengerTypeIds}
|
||||
onChange={val => setStepTwo({ ...stepTwo, passengerTypeIds: val as string[] })}
|
||||
placeholder="Select Passenger Types"
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
<CustomDropdown
|
||||
<CustomMultiSelect
|
||||
label="Ancillary Purchase"
|
||||
options={toDropdownOptions(ancillaryPurchases)}
|
||||
value={stepTwo.ancillaryPurchaseId}
|
||||
onChange={val => setStepTwo({ ...stepTwo, ancillaryPurchaseId: val })}
|
||||
placeholder="Selected Option"
|
||||
value={stepTwo.ancillaryPurchaseIds}
|
||||
onChange={val => setStepTwo({ ...stepTwo, ancillaryPurchaseIds: val as string[] })}
|
||||
placeholder="Select Ancillary Purchases"
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
</div>
|
||||
@@ -434,20 +435,20 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<CustomDropdown
|
||||
<CustomMultiSelect
|
||||
label="Loyalty Tier"
|
||||
options={toDropdownOptions(membershipTiers)}
|
||||
value={stepTwo.loyaltyTierId}
|
||||
onChange={val => setStepTwo({ ...stepTwo, loyaltyTierId: val })}
|
||||
placeholder="Selected Option"
|
||||
value={stepTwo.loyaltyTierIds}
|
||||
onChange={val => setStepTwo({ ...stepTwo, loyaltyTierIds: val as string[] })}
|
||||
placeholder="Select Loyalty Tiers"
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
<CustomDropdown
|
||||
<CustomMultiSelect
|
||||
label="Revenue Segment"
|
||||
options={toDropdownOptions(revenueSegments)}
|
||||
value={stepTwo.revenueSegmentId}
|
||||
onChange={val => setStepTwo({ ...stepTwo, revenueSegmentId: val })}
|
||||
placeholder="Selected Option"
|
||||
value={stepTwo.revenueSegmentIds}
|
||||
onChange={val => setStepTwo({ ...stepTwo, revenueSegmentIds: val as string[] })}
|
||||
placeholder="Select Revenue Segments"
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
</div>
|
||||
@@ -499,33 +500,33 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<CustomDropdown
|
||||
<CustomMultiSelect
|
||||
label="Region"
|
||||
options={toDropdownOptions(regions)}
|
||||
value={stepTwo.regionId}
|
||||
onChange={val => setStepTwo({ ...stepTwo, regionId: val })}
|
||||
placeholder="Selected Option"
|
||||
value={stepTwo.regionIds}
|
||||
onChange={val => setStepTwo({ ...stepTwo, regionIds: val as string[] })}
|
||||
placeholder="Select Regions"
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
<CustomDropdown
|
||||
<CustomMultiSelect
|
||||
label="Trip Purpose"
|
||||
options={toDropdownOptions(tripPurposes)}
|
||||
value={stepTwo.tripPurposeId}
|
||||
onChange={val => setStepTwo({ ...stepTwo, tripPurposeId: val })}
|
||||
placeholder="Selected Option"
|
||||
value={stepTwo.tripPurposeIds}
|
||||
onChange={val => setStepTwo({ ...stepTwo, tripPurposeIds: val as string[] })}
|
||||
placeholder="Select Trip Purposes"
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
<CustomInput
|
||||
label="Origin Airport (IATA)"
|
||||
placeholder="Enter"
|
||||
value={stepTwo.originAirport}
|
||||
onChange={e => setStepTwo({ ...stepTwo, originAirport: e.target.value })}
|
||||
label="Origin Airport (comma separated IATA)"
|
||||
placeholder="e.g. JFK, LAX"
|
||||
value={stepTwo.originAirports}
|
||||
onChange={e => setStepTwo({ ...stepTwo, originAirports: e.target.value })}
|
||||
/>
|
||||
<CustomInput
|
||||
label="Destination Airport (IATA)"
|
||||
placeholder="Enter"
|
||||
value={stepTwo.destinationAirport}
|
||||
onChange={e => setStepTwo({ ...stepTwo, destinationAirport: e.target.value })}
|
||||
label="Destination Airport (comma separated IATA)"
|
||||
placeholder="e.g. LHR, CDG"
|
||||
value={stepTwo.destinationAirports}
|
||||
onChange={e => setStepTwo({ ...stepTwo, destinationAirports: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { ChevronDown, X, Check } from "lucide-react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
value: string | number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface CustomMultiSelectProps {
|
||||
@@ -13,184 +14,167 @@ interface CustomMultiSelectProps {
|
||||
onChange?: (value: (string | number)[]) => void;
|
||||
placeholder?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
size?: "sm" | "md" | "lg";
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
label,
|
||||
options,
|
||||
value = [],
|
||||
onChange,
|
||||
placeholder = "Select options...",
|
||||
leftIcon,
|
||||
disabled,
|
||||
required,
|
||||
className = "",
|
||||
error,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
options,
|
||||
value = [],
|
||||
onChange,
|
||||
placeholder = "Select...",
|
||||
disabled,
|
||||
className = "",
|
||||
required,
|
||||
leftIcon,
|
||||
error,
|
||||
size = "md",
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
setSearchTerm("");
|
||||
const sizeClasses = {
|
||||
sm: "py-1.5 text-sm min-h-[36px]",
|
||||
md: "py-2.5 text-sm min-h-[42px]",
|
||||
lg: "py-3 text-base min-h-[48px]",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (option: Option) => {
|
||||
if (option.disabled) return;
|
||||
const optionValueStr = String(option.value);
|
||||
const isSelected = value.some(v => String(v) === optionValueStr);
|
||||
let newValue = [...value];
|
||||
if (isSelected) {
|
||||
newValue = newValue.filter(v => String(v) !== optionValueStr);
|
||||
} else {
|
||||
newValue.push(option.value);
|
||||
}
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
const selectedOptions = options.filter(opt => value.some(v => String(v) === String(opt.value)));
|
||||
|
||||
const handleSelect = (optionValue: string | number) => {
|
||||
if (disabled) return;
|
||||
const newValue = value.includes(optionValue)
|
||||
? value.filter((v) => v !== optionValue)
|
||||
: [...value, optionValue];
|
||||
onChange?.(newValue);
|
||||
};
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-1.5" ref={ref}>
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-gray-900">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
const removeValue = (e: React.MouseEvent, optionValue: string | number) => {
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
onChange?.(value.filter((v) => v !== optionValue));
|
||||
};
|
||||
|
||||
const selectedOptions = options.filter((opt) => value.includes(opt.value));
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-1.5" ref={containerRef}>
|
||||
{label && (
|
||||
<label className="text-sm font-semibold text-gray-900">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
className={`
|
||||
w-full rounded-lg
|
||||
bg-white text-gray-900 text-sm
|
||||
border ${isOpen
|
||||
? "border-primary ring-2 ring-primary/20"
|
||||
: error ? "border-red-500" : "border-gray-300"
|
||||
}
|
||||
px-3 py-0 h-[46px]
|
||||
outline-none
|
||||
transition-all duration-200
|
||||
hover:border-primary
|
||||
cursor-pointer
|
||||
flex items-center gap-2
|
||||
overflow-hidden
|
||||
${disabled
|
||||
? "bg-gray-50 text-gray-500 cursor-not-allowed border-gray-300"
|
||||
: ""
|
||||
}
|
||||
${leftIcon ? "pl-10" : ""}
|
||||
pr-10
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{leftIcon && (
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">
|
||||
{leftIcon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 overflow-hidden w-full h-full">
|
||||
{selectedOptions.length === 0 ? (
|
||||
<span className="text-gray-500/50 text-nowrap">{placeholder}</span>
|
||||
) : selectedOptions.length === 1 ? (
|
||||
<span
|
||||
key={selectedOptions[0].value}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded bg-primary/10 dark:bg-primary/20 text-primary dark:text-emerald-400 text-xs font-medium border border-primary/20 dark:border-emerald-800 min-w-0"
|
||||
>
|
||||
<span className="truncate">{selectedOptions[0].label}</span>
|
||||
<X
|
||||
size={14}
|
||||
className="cursor-pointer hover:text-primary dark:hover:text-emerald-200 shrink-0"
|
||||
onClick={(e) => removeValue(e, selectedOptions[0].value)}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded bg-primary/10 dark:bg-primary/20 text-primary dark:text-emerald-400 text-xs font-medium border border-primary/20 dark:border-emerald-800">
|
||||
{selectedOptions.length} Selected
|
||||
<div className="relative w-full" ref={dropdownRef}>
|
||||
<div
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
className={`
|
||||
w-full rounded-lg
|
||||
bg-white
|
||||
border ${error ? 'border-red-500' : 'border-gray-300'}
|
||||
${sizeClasses[size]}
|
||||
px-3
|
||||
outline-none
|
||||
transition-all duration-200
|
||||
flex items-center flex-wrap gap-1
|
||||
${!disabled ? 'cursor-pointer hover:border-primary' : 'cursor-not-allowed bg-gray-50 text-gray-500'}
|
||||
${isOpen ? 'border-primary ring-2 ring-primary/20' : ''}
|
||||
${leftIcon ? "pl-10" : ""}
|
||||
pr-10
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{leftIcon && (
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">
|
||||
{leftIcon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-hidden flex flex-col py-1">
|
||||
<div className="px-2 py-1 border-b border-gray-300">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="w-full px-2 py-1 text-sm bg-transparent outline-none text-gray-900 placeholder:text-gray-500"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
value={searchTerm}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-48">
|
||||
{options
|
||||
.filter(opt => opt.label.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-gray-500">
|
||||
No options available
|
||||
</div>
|
||||
<div className="flex-1 text-left text-[14px] font-medium tracking-[0.25px] leading-[15px] flex flex-wrap gap-1 items-center">
|
||||
{selectedOptions.length > 0 ? (
|
||||
selectedOptions.map(opt => (
|
||||
<span key={opt.value} className="bg-gray-100 text-[#6C766D] px-2 py-0.5 rounded text-xs border border-gray-200">
|
||||
{opt.label}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
options
|
||||
.filter(opt => opt.label.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.map((option) => {
|
||||
const isSelected = value.includes(option.value);
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
className={`
|
||||
px-3 py-2 text-sm cursor-pointer flex items-center justify-between
|
||||
${isSelected
|
||||
? "bg-[#F0FDF4] text-primary-dark font-medium"
|
||||
: "text-gray-900 hover:bg-gray-50"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{option.label}
|
||||
{isSelected && <Check size={16} className="text-primary" strokeWidth={2.5} />}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
<span style={{ color: '#6C766D' }}>{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none flex items-center">
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && !disabled && (
|
||||
<div className="absolute z-[9999] w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto flex flex-col">
|
||||
{options.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 text-center">No options available</div>
|
||||
) : (
|
||||
options.map((option) => {
|
||||
const isSelected = value.some(v => String(v) === String(option.value));
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(option);
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-4 py-2.5 text-[15px]
|
||||
transition-colors duration-150 flex items-center gap-2
|
||||
${isSelected
|
||||
? "bg-[#F0FDF4] text-[#1B9869] font-medium"
|
||||
: option.disabled
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${isSelected ? 'bg-[#1B9869] border-[#1B9869]' : 'border-gray-300'}`}>
|
||||
{isSelected && <Check size={12} className="text-white" strokeWidth={3} />}
|
||||
</div>
|
||||
<span className="ml-1">{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CustomMultiSelect.displayName = "CustomMultiSelect";
|
||||
|
||||
export default CustomMultiSelect;
|
||||
|
||||
Reference in New Issue
Block a user