feat: implement AddCohort form with multi-select support and step-based navigation

This commit is contained in:
azeeee05
2026-07-10 10:42:46 +05:30
parent 9a9bcfd3cd
commit d3f5d494ca
3 changed files with 241 additions and 256 deletions
+18 -18
View File
@@ -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> {}
+79 -78
View File
@@ -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>
+144 -160
View File
@@ -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;