feat: implement policy engine, action builder, and master data management modules with shared custom UI components

This commit is contained in:
Syed Waseem
2026-08-03 11:44:46 +05:30
parent fb848d181f
commit 95bfe33ddc
17 changed files with 1716 additions and 690 deletions
+1
View File
@@ -17,6 +17,7 @@ function AppRoutes() {
<Route path="/cohorts" element={<CohortManage />} />
<Route path="/policy-engine" element={<PolicyEngineList />} />
<Route path="/policy-engine/add" element={<AddPolicyEngine />} />
<Route path="/policy-engine/edit/:id" element={<AddPolicyEngine />} />
<Route path="/action-builder" element={<ActionBuilderPage />} />
<Route path="/config" element={<MasterDataManagement />} />
<Route path="/recovery" element={<RecoveryIncidentsList />} />
+10 -5
View File
@@ -98,14 +98,19 @@ export function submitActionPayload(payload: ActionSubmissionPayload): Promise<a
// ─── Master Data Options Loader ──────────────────────────────────────────────
export function getMasterDataOptions(categoryCode: string): Promise<{ label: string; value: string }[]> {
export function getMasterDataOptions(categoryCode: string): Promise<{ label: string; value: string; id?: string }[]> {
return ApiClient.get<any, any[]>(`/master-data/category-values/${categoryCode}`)
.then((res) => {
if (Array.isArray(res)) {
return res.map((item) => ({
label: item.value || item.name || item.code,
value: item.code || item.value,
}));
return res.map((item) => {
const val = item.id || item.value || item.code || item.label || item.name || '';
const lbl = item.label || item.name || item.value || item.code || '';
return {
label: lbl,
value: val,
id: item.id || val,
};
});
}
return [];
})
+13 -1
View File
@@ -118,5 +118,17 @@ export interface FieldDefinitionFormData {
export interface ActionSubmissionPayload {
category_id: string;
action_type_id: string;
data: Record<string, any>;
values: ActionSubmissionValue[];
}
export interface ActionSubmissionValue {
field_definition_id: string;
value_index?: number;
selected_value_id?: string;
text_value?: string;
number_value?: number;
boolean_value?: boolean;
date_value?: string;
time_value?: string;
timestamp_value?: string;
}
@@ -29,6 +29,8 @@ import type {
ActionType,
FieldDefinition,
FieldWidth,
ActionSubmissionPayload,
ActionSubmissionValue,
} from '../ActionBuilderTypes';
const WIDTH_GRID_MAP: Record<FieldWidth, string> = {
@@ -38,7 +40,7 @@ const WIDTH_GRID_MAP: Record<FieldWidth, string> = {
two_thirds: 'col-span-12 md:col-span-8',
};
const DEFAULT_CURRENCIES = [
export const DEFAULT_CURRENCIES = [
{ label: 'INR (Indian Rupee)', value: 'INR' },
{ label: 'USD (US Dollar)', value: 'USD' },
{ label: 'EUR (Euro)', value: 'EUR' },
@@ -298,18 +300,67 @@ export function DynamicFormRenderer() {
if (!validateForm()) return;
// Filter out hidden field values from final payload
const activeDataPayload: Record<string, any> = {};
fields.forEach((f) => {
if (checkVisibility(f)) {
activeDataPayload[f.fieldCode] = formValues[f.fieldCode];
const values: ActionSubmissionValue[] = [];
fields.filter(checkVisibility).forEach((field) => {
const value = formValues[field.fieldCode];
if (value === undefined || value === null || value === '') return;
const base = { field_definition_id: field.id };
if (field.fieldType === 'currency') {
const amt = typeof value === 'object' && value !== null ? value.amount : value;
const curr = typeof value === 'object' && value !== null ? (value.currency || value.id) : undefined;
values.push({
...base,
number_value: amt !== undefined && amt !== null && amt !== '' ? Number(amt) : undefined,
selected_value_id: curr ? String(curr) : undefined,
});
} else if (field.fieldType === 'dropdown' || field.fieldType === 'radio') {
const valStr = typeof value === 'object' && value !== null ? String(value.value || value.id || '') : String(value);
values.push({
...base,
selected_value_id: valStr,
text_value: valStr,
});
} else if (field.fieldType === 'multi_select') {
(Array.isArray(value) ? value : []).forEach((item, valueIndex) => {
const itemStr = typeof item === 'object' && item !== null ? String(item.value || item.id || '') : String(item);
values.push({
...base,
value_index: valueIndex,
selected_value_id: itemStr,
text_value: itemStr,
});
});
} else if (field.fieldType === 'checkbox' || field.fieldType === 'switch') {
values.push({ ...base, boolean_value: Boolean(value) });
} else if (field.fieldType === 'number' || field.fieldType === 'decimal' || field.fieldType === 'percentage') {
values.push({ ...base, number_value: Number(value) });
} else if (field.fieldType === 'date') {
values.push({ ...base, date_value: String(value) });
} else if (field.fieldType === 'time') {
values.push({ ...base, time_value: String(value) });
} else if (field.fieldType === 'datetime') {
values.push({ ...base, timestamp_value: String(value) });
} else if (field.lookupSource) {
const valStr = typeof value === 'object' && value !== null ? String(value.value || value.id || '') : String(value);
values.push({
...base,
selected_value_id: valStr,
text_value: valStr,
});
} else {
values.push({
...base,
text_value: typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value),
});
}
});
const payload = {
const payload: ActionSubmissionPayload = {
category_id: selectedCategoryId,
action_type_id: selectedActionTypeId,
data: activeDataPayload,
values,
};
setSubmitting(true);
@@ -0,0 +1,89 @@
import React from 'react';
import { ListBulletsIcon, MagnifyingGlassIcon, DatabaseIcon } from '@phosphor-icons/react';
import { CustomInput, CustomLoader } from '../../../components/custom';
import type { MasterDataCategoryItem } from '../MasterDataTypes';
interface CategorySidebarProps {
filteredCategories: MasterDataCategoryItem[];
selectedCategory: MasterDataCategoryItem | null;
onSelectCategory: (category: MasterDataCategoryItem) => void;
loading: boolean;
searchQuery: string;
onSearchChange: (query: string) => void;
}
export const CategorySidebar: React.FC<CategorySidebarProps> = ({
filteredCategories,
selectedCategory,
onSelectCategory,
loading,
searchQuery,
onSearchChange,
}) => {
return (
<div className="lg:col-span-4 bg-white rounded-[20px] border border-gray-200 shadow-sm p-4 space-y-4 h-fit">
<div className="flex items-center justify-between border-b border-gray-100 pb-3">
<h3 className="text-[15px] font-bold text-[#0F172B] flex items-center gap-2">
<ListBulletsIcon size={20} className="text-[#1E7D5C]" />
Master Categories
</h3>
<span className="text-xs font-mono font-semibold text-slate-400">
{filteredCategories.length} Categories
</span>
</div>
{/* Search Category */}
<CustomInput
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search categories..."
leftIcon={<MagnifyingGlassIcon size={16} />}
size="sm"
/>
{/* Category List */}
{loading ? (
<div className="py-12 flex justify-center">
<CustomLoader label="Loading Categories..." />
</div>
) : filteredCategories.length === 0 ? (
<div className="py-8 text-center text-slate-400 text-[13px]">
No matching categories found.
</div>
) : (
<div className="space-y-1.5 max-h-[560px] overflow-y-auto pr-1">
{filteredCategories.map((cat) => {
const isSelected = selectedCategory?.code === cat.code;
return (
<button
key={cat.id || cat.code}
onClick={() => onSelectCategory(cat)}
className={`w-full text-left p-3 rounded-[12px] transition-all flex items-center justify-between group ${
isSelected
? 'bg-[#1E7D5C] text-white shadow-sm font-semibold'
: 'bg-slate-50/70 hover:bg-slate-100 text-slate-700 font-medium'
}`}
>
<div className="overflow-hidden pr-2">
<span className="block text-[13px] truncate">{cat.name}</span>
<span
className={`text-[11px] font-mono truncate block ${
isSelected ? 'text-emerald-100' : 'text-slate-400'
}`}
>
{cat.code}
</span>
</div>
<DatabaseIcon
size={16}
className={isSelected ? 'text-white' : 'text-slate-400 group-hover:text-slate-600'}
/>
</button>
);
})}
</div>
)}
</div>
);
};
@@ -0,0 +1,45 @@
import React from 'react';
import { GearIcon } from '@phosphor-icons/react';
interface MasterDataHeaderProps {
categoriesCount: number;
itemsCount: number;
}
export const MasterDataHeader: React.FC<MasterDataHeaderProps> = ({
categoriesCount,
itemsCount,
}) => {
return (
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white p-6 rounded-[24px] border border-gray-200 shadow-sm">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-[16px] bg-[#E8F3EF] text-[#1E7D5C] flex items-center justify-center shrink-0 shadow-inner">
<GearIcon size={26} weight="fill" />
</div>
<div>
<h1 className="text-2xl font-extrabold text-[#0F172B]">Master Data Configurations</h1>
<p className="text-[14px] text-slate-500 mt-1">
Manage system rule categories, lookup values, and master data parameters for AeroResolve
</p>
</div>
</div>
{/* Stats Pills */}
<div className="flex items-center gap-3">
<div className="px-4 py-2 bg-slate-50 border border-slate-200 rounded-[14px] text-center">
<span className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider">
Categories
</span>
<span className="text-lg font-black text-[#1E7D5C]">{categoriesCount}</span>
</div>
<div className="px-4 py-2 bg-slate-50 border border-slate-200 rounded-[14px] text-center">
<span className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider">
Loaded Items
</span>
<span className="text-lg font-black text-[#1E7D5C]">{itemsCount}</span>
</div>
</div>
</div>
);
};
@@ -0,0 +1,121 @@
import React from 'react';
import { XCircleIcon } from '@phosphor-icons/react';
import {
CustomModal,
CustomInput,
CustomSwitch,
CustomButton,
} from '../../../components/custom';
import type {
MasterDataCategoryItem,
MasterDataItem,
MasterDataValueFormData,
} from '../MasterDataTypes';
interface MasterItemFormModalProps {
isOpen: boolean;
onClose: () => void;
selectedCategory: MasterDataCategoryItem | null;
editingItem: MasterDataItem | null;
formData: MasterDataValueFormData;
setFormData: React.Dispatch<React.SetStateAction<MasterDataValueFormData>>;
onSubmit: (e: React.FormEvent) => void;
errorMsg: string | null;
}
export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
isOpen,
onClose,
selectedCategory,
editingItem,
formData,
setFormData,
onSubmit,
errorMsg,
}) => {
return (
<CustomModal
isOpen={isOpen}
onClose={onClose}
title={editingItem ? `Edit ${selectedCategory?.name || 'Item'}` : `Add ${selectedCategory?.name || 'Item'}`}
description={`Configure item value for category: ${selectedCategory?.name}`}
size="md"
>
{errorMsg && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-[#D40000] rounded-[10px] text-[13px] font-medium flex items-center gap-2">
<XCircleIcon size={18} weight="fill" />
{errorMsg}
</div>
)}
<form onSubmit={onSubmit} className="space-y-4 pt-1">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Value / Label <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.value}
onChange={(e) => {
const val = e.target.value;
setFormData((prev) => ({
...prev,
value: val,
code: editingItem ? prev.code : val.toLowerCase().replace(/[^a-z0-9_]+/g, '_'),
}));
}}
placeholder="e.g. Economy Class"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Code Key</label>
<CustomInput
value={formData.code || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, code: e.target.value }))}
placeholder="e.g. economy_class"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Display Order</label>
<CustomInput
type="number"
value={String(formData.displayOrder || 1)}
onChange={(e) =>
setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 1 }))
}
/>
</div>
<div className="flex items-center justify-between pt-3 border-t border-gray-100">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-slate-700">Active</span>
<CustomSwitch
checked={formData.isActive}
onChange={(e) => setFormData((prev) => ({ ...prev, isActive: e.target.checked }))}
/>
</div>
<div className="flex items-center gap-3">
<CustomButton
variant="outlined"
type="button"
onClick={onClose}
className="!border-gray-300 !text-gray-600 px-4"
>
Cancel
</CustomButton>
<CustomButton
variant="primary"
type="submit"
className="!bg-[#1E7D5C] hover:!bg-[#17664B] px-6"
>
{editingItem ? 'Save Changes' : 'Create Item'}
</CustomButton>
</div>
</div>
</form>
</CustomModal>
);
};
@@ -0,0 +1,195 @@
import React from 'react';
import {
PlusIcon,
MagnifyingGlassIcon,
PencilSimpleIcon,
TrashIcon,
CaretLeftIcon,
CaretRightIcon,
} from '@phosphor-icons/react';
import {
CustomButton,
CustomInput,
CustomLoader,
CustomStatus,
} from '../../../components/custom';
import type { MasterDataCategoryItem, MasterDataItem } from '../MasterDataTypes';
interface MasterItemTableProps {
selectedCategory: MasterDataCategoryItem | null;
filteredItems: MasterDataItem[];
paginatedItems: MasterDataItem[];
loading: boolean;
searchQuery: string;
onSearchChange: (query: string) => void;
currentPage: number;
totalPages: number;
pageSize: number;
onPageChange: (pageUpdater: (prev: number) => number) => void;
onOpenCreate: () => void;
onOpenEdit: (item: MasterDataItem) => void;
onOpenDelete: (item: MasterDataItem) => void;
}
export const MasterItemTable: React.FC<MasterItemTableProps> = ({
selectedCategory,
filteredItems,
paginatedItems,
loading,
searchQuery,
onSearchChange,
currentPage,
totalPages,
pageSize,
onPageChange,
onOpenCreate,
onOpenEdit,
onOpenDelete,
}) => {
return (
<div className="lg:col-span-8 bg-white rounded-[20px] border border-gray-200 shadow-sm overflow-hidden flex flex-col justify-between">
<div>
{/* Header for Selected Category */}
<div className="p-5 border-b border-gray-100 bg-slate-50/60 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 className="text-[17px] font-extrabold text-[#0F172B]">
{selectedCategory ? selectedCategory.name : 'Select a Category'}
</h3>
<p className="text-[12px] text-slate-500 mt-0.5 font-mono">
{selectedCategory
? `Code: ${selectedCategory.code} | Table: ${selectedCategory.tableName || 'masters'}`
: ''}
</p>
</div>
<CustomButton
variant="primary"
onClick={onOpenCreate}
size='sm'
disabled={!selectedCategory}
leftIcon={<PlusIcon size={18} />}
className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[12px] text-[13px] font-semibold"
>
Add Master Item
</CustomButton>
</div>
{/* Filter Search Bar */}
<div className="p-4 border-b border-gray-100 bg-white">
<CustomInput
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={`Search ${selectedCategory?.name || 'items'}...`}
leftIcon={<MagnifyingGlassIcon size={16} />}
size="sm"
/>
</div>
{/* Items Table */}
{loading ? (
<div className="py-16 flex justify-center">
<CustomLoader label="Loading Master Data Items..." />
</div>
) : filteredItems.length === 0 ? (
<div className="py-16 text-center text-slate-400 space-y-2">
<p className="text-[14px]">No master data items found in this category.</p>
<p className="text-[12px]">Click "Add Master Item" above to create an entry.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-100/70 border-b border-gray-200 text-[12px] font-bold text-slate-600 uppercase tracking-wider">
<th className="py-3.5 px-5 w-16">#</th>
<th className="py-3.5 px-5">Label / Value</th>
<th className="py-3.5 px-5">Code Key</th>
<th className="py-3.5 px-5">Status</th>
<th className="py-3.5 px-5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-[13px]">
{paginatedItems.map((item, idx) => {
const displayVal = item.value || item.label || item.name || '';
const displayCode =
item.code || displayVal.toLowerCase().replace(/[^a-z0-9_]+/g, '_');
const orderNum = item.displayOrder || (currentPage - 1) * pageSize + idx + 1;
return (
<tr key={item.id} className="hover:bg-slate-50/80 transition-colors">
<td className="py-3.5 px-5 font-mono text-slate-400 text-xs">
#{orderNum}
</td>
<td className="py-3.5 px-5 font-bold text-slate-800">
{displayVal}
</td>
<td className="py-3.5 px-5">
<span className="inline-block px-2.5 py-0.5 rounded font-mono text-[11px] bg-slate-100 text-slate-600 border border-slate-200">
{displayCode}
</span>
</td>
<td className="py-3.5 px-5">
<CustomStatus status={item.isActive !== false ? 'Active' : 'Inactive'} />
</td>
<td className="py-3.5 px-5 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => onOpenEdit(item)}
className="p-2 rounded-lg text-slate-500 hover:text-[#1E7D5C] hover:bg-[#E8F3EF] transition-colors"
title="Edit Item"
>
<PencilSimpleIcon size={17} weight="bold" />
</button>
<button
onClick={() => onOpenDelete(item)}
className="p-2 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition-colors"
title="Delete Item"
>
<TrashIcon size={17} weight="bold" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Pagination Footer */}
{filteredItems.length > 0 && (
<div className="p-4 border-t border-gray-100 bg-slate-50/40 flex items-center justify-between">
<span className="text-[13px] text-slate-500">
Showing {Math.min((currentPage - 1) * pageSize + 1, filteredItems.length)} to{' '}
{Math.min(currentPage * pageSize, filteredItems.length)} of {filteredItems.length} entries
</span>
<div className="flex items-center gap-1.5">
<button
onClick={() => onPageChange((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-2 rounded-lg border border-gray-200 bg-white text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<CaretLeftIcon size={16} />
</button>
<span className="px-3 text-[13px] font-semibold text-slate-700">
{currentPage} / {totalPages}
</span>
<button
onClick={() => onPageChange((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-2 rounded-lg border border-gray-200 bg-white text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<CaretRightIcon size={16} />
</button>
</div>
</div>
)}
</div>
);
};
+4
View File
@@ -0,0 +1,4 @@
export { MasterDataHeader } from './MasterDataHeader';
export { CategorySidebar } from './CategorySidebar';
export { MasterItemTable } from './MasterItemTable';
export { MasterItemFormModal } from './MasterItemFormModal';
+48 -327
View File
@@ -1,26 +1,6 @@
import React, { useState, useEffect } from 'react';
import {
GearIcon,
PlusIcon,
PencilSimpleIcon,
TrashIcon,
MagnifyingGlassIcon,
CheckCircleIcon,
XCircleIcon,
DatabaseIcon,
CaretLeftIcon,
CaretRightIcon,
ListBulletsIcon,
} from '@phosphor-icons/react';
import {
CustomInput,
CustomButton,
CustomSwitch,
CustomStatus,
CustomConfirmationModal,
CustomLoader,
CustomModal,
} from '../../components/custom';
import { CheckCircleIcon } from '@phosphor-icons/react';
import { CustomConfirmationModal } from '../../components/custom';
import {
getMasterCategories,
getCategoryValues,
@@ -33,6 +13,12 @@ import type {
MasterDataItem,
MasterDataValueFormData,
} from './MasterDataTypes';
import {
MasterDataHeader,
CategorySidebar,
MasterItemTable,
MasterItemFormModal,
} from './components';
const PAGE_SIZE = 10;
@@ -210,33 +196,11 @@ export default function MasterDataManagement() {
return (
<div className="p-6 md:p-8 max-w-7xl mx-auto space-y-6 font-sans">
{/* Top Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white p-6 rounded-[24px] border border-gray-200 shadow-sm">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-[16px] bg-[#E8F3EF] text-[#1E7D5C] flex items-center justify-center shrink-0 shadow-inner">
<GearIcon size={26} weight="fill" />
</div>
<div>
<h1 className="text-2xl font-extrabold text-[#0F172B]">Master Data Configurations</h1>
<p className="text-[14px] text-slate-500 mt-1">
Manage system rule categories, lookup values, and master data parameters for AeroResolve
</p>
</div>
</div>
{/* Stats Pills */}
<div className="flex items-center gap-3">
<div className="px-4 py-2 bg-slate-50 border border-slate-200 rounded-[14px] text-center">
<span className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider">Categories</span>
<span className="text-lg font-black text-[#1E7D5C]">{categories.length}</span>
</div>
<div className="px-4 py-2 bg-slate-50 border border-slate-200 rounded-[14px] text-center">
<span className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider">Loaded Items</span>
<span className="text-lg font-black text-[#1E7D5C]">{items.length}</span>
</div>
</div>
</div>
{/* Top Header Component */}
<MasterDataHeader
categoriesCount={categories.length}
itemsCount={items.length}
/>
{/* Success Notification Banner */}
{successMsg && (
@@ -246,293 +210,50 @@ export default function MasterDataManagement() {
</div>
)}
{/* Main Split Layout: Left Categories List + Right Values Table */}
{/* Main Split Layout */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left Panel: Categories Selector */}
<div className="lg:col-span-4 bg-white rounded-[20px] border border-gray-200 shadow-sm p-4 space-y-4 h-fit">
<div className="flex items-center justify-between border-b border-gray-100 pb-3">
<h3 className="text-[15px] font-bold text-[#0F172B] flex items-center gap-2">
<ListBulletsIcon size={20} className="text-[#1E7D5C]" />
Master Categories
</h3>
<span className="text-xs font-mono font-semibold text-slate-400">
{filteredCategories.length} Categories
</span>
</div>
{/* Search Category */}
<CustomInput
value={categorySearch}
onChange={(e) => setCategorySearch(e.target.value)}
placeholder="Search categories..."
leftIcon={<MagnifyingGlassIcon size={16} />}
size="sm"
/>
{/* Category List */}
{loadingCategories ? (
<div className="py-12 flex justify-center">
<CustomLoader label="Loading Categories..." />
</div>
) : filteredCategories.length === 0 ? (
<div className="py-8 text-center text-slate-400 text-[13px]">
No matching categories found.
</div>
) : (
<div className="space-y-1.5 max-h-[560px] overflow-y-auto pr-1">
{filteredCategories.map((cat) => {
const isSelected = selectedCategory?.code === cat.code;
return (
<button
key={cat.id || cat.code}
onClick={() => setSelectedCategory(cat)}
className={`w-full text-left p-3 rounded-[12px] transition-all flex items-center justify-between group ${
isSelected
? 'bg-[#1E7D5C] text-white shadow-sm font-semibold'
: 'bg-slate-50/70 hover:bg-slate-100 text-slate-700 font-medium'
}`}
>
<div className="overflow-hidden pr-2">
<span className="block text-[13px] truncate">{cat.name}</span>
<span
className={`text-[11px] font-mono truncate block ${
isSelected ? 'text-emerald-100' : 'text-slate-400'
}`}
>
{cat.code}
</span>
</div>
<DatabaseIcon
size={16}
className={isSelected ? 'text-white' : 'text-slate-400 group-hover:text-slate-600'}
/>
</button>
);
})}
</div>
)}
</div>
<CategorySidebar
filteredCategories={filteredCategories}
selectedCategory={selectedCategory}
onSelectCategory={setSelectedCategory}
loading={loadingCategories}
searchQuery={categorySearch}
onSearchChange={setCategorySearch}
/>
{/* Right Panel: Category Items Table */}
<div className="lg:col-span-8 bg-white rounded-[20px] border border-gray-200 shadow-sm overflow-hidden flex flex-col justify-between">
<div>
{/* Header for Selected Category */}
<div className="p-5 border-b border-gray-100 bg-slate-50/60 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 className="text-[17px] font-extrabold text-[#0F172B]">
{selectedCategory ? selectedCategory.name : 'Select a Category'}
</h3>
<p className="text-[12px] text-slate-500 mt-0.5 font-mono">
{selectedCategory ? `Code: ${selectedCategory.code} | Table: ${selectedCategory.tableName || 'masters'}` : ''}
</p>
</div>
<CustomButton
variant="primary"
onClick={handleOpenCreate}
disabled={!selectedCategory}
leftIcon={<PlusIcon size={18} />}
className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[12px] text-[13px] font-semibold"
>
Add Master Item
</CustomButton>
</div>
{/* Filter Search Bar */}
<div className="p-4 border-b border-gray-100 bg-white">
<CustomInput
value={itemSearch}
onChange={(e) => {
setItemSearch(e.target.value);
setCurrentPage(1);
}}
placeholder={`Search ${selectedCategory?.name || 'items'}...`}
leftIcon={<MagnifyingGlassIcon size={16} />}
size="sm"
/>
</div>
{/* Items Table */}
{loadingItems ? (
<div className="py-16 flex justify-center">
<CustomLoader label="Loading Master Data Items..." />
</div>
) : filteredItems.length === 0 ? (
<div className="py-16 text-center text-slate-400 space-y-2">
<p className="text-[14px]">No master data items found in this category.</p>
<p className="text-[12px]">Click "Add Master Item" above to create an entry.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-100/70 border-b border-gray-200 text-[12px] font-bold text-slate-600 uppercase tracking-wider">
<th className="py-3.5 px-5 w-16">#</th>
<th className="py-3.5 px-5">Label / Value</th>
<th className="py-3.5 px-5">Code Key</th>
<th className="py-3.5 px-5">Status</th>
<th className="py-3.5 px-5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-[13px]">
{paginatedItems.map((item, idx) => {
const displayVal = item.value || item.label || item.name || '';
const displayCode = item.code || displayVal.toLowerCase().replace(/[^a-z0-9_]+/g, '_');
const orderNum = item.displayOrder || (currentPage - 1) * PAGE_SIZE + idx + 1;
return (
<tr key={item.id} className="hover:bg-slate-50/80 transition-colors">
<td className="py-3.5 px-5 font-mono text-slate-400 text-xs">
#{orderNum}
</td>
<td className="py-3.5 px-5 font-bold text-slate-800">
{displayVal}
</td>
<td className="py-3.5 px-5">
<span className="inline-block px-2.5 py-0.5 rounded font-mono text-[11px] bg-slate-100 text-slate-600 border border-slate-200">
{displayCode}
</span>
</td>
<td className="py-3.5 px-5">
<CustomStatus status={item.isActive !== false ? 'Active' : 'Inactive'} />
</td>
<td className="py-3.5 px-5 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleOpenEdit(item)}
className="p-2 rounded-lg text-slate-500 hover:text-[#1E7D5C] hover:bg-[#E8F3EF] transition-colors"
title="Edit Item"
>
<PencilSimpleIcon size={17} weight="bold" />
</button>
<button
onClick={() => setDeleteTarget(item)}
className="p-2 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition-colors"
title="Delete Item"
>
<TrashIcon size={17} weight="bold" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Pagination Footer */}
{filteredItems.length > 0 && (
<div className="p-4 border-t border-gray-100 bg-slate-50/40 flex items-center justify-between">
<span className="text-[13px] text-slate-500">
Showing {Math.min((currentPage - 1) * PAGE_SIZE + 1, filteredItems.length)} to{' '}
{Math.min(currentPage * PAGE_SIZE, filteredItems.length)} of {filteredItems.length} entries
</span>
<div className="flex items-center gap-1.5">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-2 rounded-lg border border-gray-200 bg-white text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<CaretLeftIcon size={16} />
</button>
<span className="px-3 text-[13px] font-semibold text-slate-700">
{currentPage} / {totalPages}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-2 rounded-lg border border-gray-200 bg-white text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<CaretRightIcon size={16} />
</button>
</div>
</div>
)}
</div>
<MasterItemTable
selectedCategory={selectedCategory}
filteredItems={filteredItems}
paginatedItems={paginatedItems}
loading={loadingItems}
searchQuery={itemSearch}
onSearchChange={(query) => {
setItemSearch(query);
setCurrentPage(1);
}}
currentPage={currentPage}
totalPages={totalPages}
pageSize={PAGE_SIZE}
onPageChange={setCurrentPage}
onOpenCreate={handleOpenCreate}
onOpenEdit={handleOpenEdit}
onOpenDelete={setDeleteTarget}
/>
</div>
{/* Add / Edit Master Data Item Modal */}
<CustomModal
<MasterItemFormModal
isOpen={isModalOpen}
onClose={resetForm}
title={editingItem ? `Edit ${selectedCategory?.name || 'Item'}` : `Add ${selectedCategory?.name || 'Item'}`}
description={`Configure item value for category: ${selectedCategory?.name}`}
size="md"
>
{errorMsg && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-[#D40000] rounded-[10px] text-[13px] font-medium flex items-center gap-2">
<XCircleIcon size={18} weight="fill" />
{errorMsg}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4 pt-1">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Value / Label <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.value}
onChange={(e) => {
const val = e.target.value;
setFormData((prev) => ({
...prev,
value: val,
code: editingItem ? prev.code : val.toLowerCase().replace(/[^a-z0-9_]+/g, '_'),
}));
}}
placeholder="e.g. Economy Class"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Code Key</label>
<CustomInput
value={formData.code || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, code: e.target.value }))}
placeholder="e.g. economy_class"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Display Order</label>
<CustomInput
type="number"
value={String(formData.displayOrder || 1)}
onChange={(e) => setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 1 }))}
/>
</div>
<div className="flex items-center justify-between pt-3 border-t border-gray-100">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-slate-700">Active</span>
<CustomSwitch
checked={formData.isActive}
onChange={(e) => setFormData((prev) => ({ ...prev, isActive: e.target.checked }))}
/>
</div>
<div className="flex items-center gap-3">
<CustomButton variant="outlined" type="button" onClick={resetForm} className="!border-gray-300 !text-gray-600 px-4">
Cancel
</CustomButton>
<CustomButton variant="primary" type="submit" className="!bg-[#1E7D5C] hover:!bg-[#17664B] px-6">
{editingItem ? 'Save Changes' : 'Create Item'}
</CustomButton>
</div>
</div>
</form>
</CustomModal>
selectedCategory={selectedCategory}
editingItem={editingItem}
formData={formData}
setFormData={setFormData}
onSubmit={handleSubmit}
errorMsg={errorMsg}
/>
{/* Delete Confirmation Modal */}
{deleteTarget && (
+296
View File
@@ -0,0 +1,296 @@
import { ApiClient } from '../api/ApiClient';
import type { PolicyEngineResponse, PaginatedPolicyEngineResponse } from './PolicyEngineTypes';
export interface OptionItem {
label: string;
value: string;
id?: string;
code?: string;
}
export interface ActionTypeField {
id: string;
actionTypeId: string;
fieldCode: string;
fieldName: string;
fieldType: string;
lookupSource?: string;
isRequired: boolean;
defaultValue?: string;
placeholder?: string;
helpText?: string;
width?: string;
section?: string;
displayOrder?: number;
isActive: boolean;
}
// ─── Local Direct Api Helpers for Self-Containment ───────────────────────────
function normalizeItems(items: any[]): any[] {
if (!Array.isArray(items)) return [];
return items.map((item) => {
const val = item.code || item.id || item.value || item.label || item.name || '';
const lbl = item.label || item.name || item.value || item.code || '';
return {
...item,
id: item.id || val,
value: val,
label: lbl,
code: item.code || val,
isActive: item.isActive !== false,
};
});
}
function fetchCategoryValues(categoryCodeOrId: string): Promise<any[]> {
if (!categoryCodeOrId) return Promise.resolve([]);
return ApiClient.get<any, any[]>(`/master-data/category-values/${categoryCodeOrId}`)
.then((res) => normalizeItems(res))
.catch(() =>
ApiClient.get<any, any[]>(`/master-data/${categoryCodeOrId}`)
.then((res) => normalizeItems(res))
.catch(() => []),
);
}
function fetchMastersCategories(): Promise<any[]> {
return ApiClient.get<any, any[]>('/master-data/categories')
.then((res) => (Array.isArray(res) ? res : []))
.catch(() => []);
}
function fetchActionCategories(): Promise<any[]> {
return ApiClient.get<any, any[]>('/master-data/action-categories')
.then((res) => (Array.isArray(res) ? res : []))
.catch(() => []);
}
function fetchActionTypesByCategory(categoryId: string): Promise<any[]> {
if (!categoryId) return Promise.resolve([]);
return ApiClient.get<any, any[]>(`/master-data/action-types/category-id/${categoryId}`)
.then((res) => (Array.isArray(res) ? res : []))
.catch(() => []);
}
function fetchCohorts(): Promise<any[]> {
return ApiClient.get<any, any>('/cohorts')
.then((res) => (Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : []))
.catch(() => []);
}
// ─── Master Data & Dynamic Options for Policy Engine ────────────────────────
export function getJurisdictionOptions(): Promise<OptionItem[]> {
return fetchCategoryValues('jurisdiction')
.then((items) =>
(items || [])
.filter((i) => i.isActive !== false)
.map((i) => ({
label: i.label || i.name || i.value || '',
value: i.id || i.code || i.value || '',
id: i.id,
code: i.code,
})),
)
.catch(() => []);
}
export function getCohortOptions(): Promise<OptionItem[]> {
return fetchCohorts()
.then((list) =>
(list || [])
.filter((c) => c.status !== 'Inactive')
.map((c) => ({
label: c.name,
value: c.id || c.code || c.name,
id: c.id,
code: c.code || c.name,
})),
)
.catch(() => []);
}
export function getRuleCategoryOptions(): Promise<OptionItem[]> {
return fetchMastersCategories()
.then((cats) =>
(cats || [])
.filter((c) => c.isActive !== false)
.map((c) => ({
label: c.name,
value: c.code || c.id || c.name,
id: c.id,
code: c.code || c.name,
})),
)
.catch(() => []);
}
/**
* Action Categories for Strategic Action Builder dropdown
*/
export function getActionCategoryOptions(): Promise<OptionItem[]> {
return fetchActionCategories()
.then((cats) =>
(cats || [])
.filter((c) => c.isActive !== false)
.map((c) => ({
label: c.name,
value: c.id,
id: c.id,
code: c.code,
})),
)
.catch(() => []);
}
/**
* Action Types filtered by selected Action Category
*/
export function getActionTypesByCategoryOptions(categoryId: string): Promise<OptionItem[]> {
return fetchActionTypesByCategory(categoryId)
.then((types) =>
(types || [])
.filter((t) => t.isActive !== false)
.map((t) => ({
label: t.name,
value: t.id,
id: t.id,
code: t.code,
})),
)
.catch(() => []);
}
/**
* Fetches condition options dynamically based on the selected Rule Category code or ID.
*/
export function getConditionOptions(categoryCodeOrId?: string): Promise<OptionItem[]> {
if (!categoryCodeOrId || !categoryCodeOrId.trim()) {
return Promise.resolve([]);
}
const code = categoryCodeOrId.trim();
return fetchCategoryValues(code)
.then((items) =>
(items || [])
.filter((i) => i.isActive !== false)
.map((i) => ({
label: i.label || i.name || i.value || '',
value: i.id || i.code || i.value || '',
id: i.id,
code: i.code,
})),
)
.catch(() => []);
}
export function getOperatorOptions(): Promise<OptionItem[]> {
return fetchCategoryValues('operator')
.then((items) =>
(items || [])
.filter((i) => i.isActive !== false)
.map((i) => ({
label: i.label || i.name || i.value || '',
value: i.id || i.code || i.value || '',
id: i.id,
code: i.code,
})),
)
.catch(() => []);
}
export function getActionTypeFields(actionTypeId: string): Promise<ActionTypeField[]> {
if (!actionTypeId) return Promise.resolve([]);
return ApiClient.get<any, ActionTypeField[]>(`/master-data/action-types/${actionTypeId}/fields`)
.then((res) => (Array.isArray(res) ? res.filter((f) => f.isActive !== false) : []))
.catch(() => []);
}
export function getLookupOptions(lookupSource: string): Promise<OptionItem[]> {
if (!lookupSource) return Promise.resolve([]);
return fetchCategoryValues(lookupSource.toLowerCase())
.then((items) =>
(items || [])
.filter((i) => i.isActive !== false)
.map((i) => ({
label: i.label || i.name || i.value || '',
value: i.code || i.id || i.value || '',
id: i.id,
code: i.code,
})),
)
.catch(() => []);
}
// ─── Policy CRUD Endpoints ───────────────────────────────────────────────────
export function getPolicies(page = 1, limit = 10, search = ''): Promise<PaginatedPolicyEngineResponse> {
let url = `/policy-engine?page=${page}&limit=${limit}`;
if (search) {
url += `&search=${encodeURIComponent(search)}`;
}
return ApiClient.get<any, any>(url).then((res) => {
const rawData = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
const formattedData = rawData.map((p: any) => {
const statusMap: Record<string, 'Active' | 'Inactive' | 'Draft'> = {
active: 'Active',
inactive: 'Inactive',
draft: 'Draft',
archived: 'Inactive',
};
const formattedStatus = statusMap[String(p.status).toLowerCase()] || 'Active';
const formattedDate = p.updatedAt || p.createdAt
? new Date(p.updatedAt || p.createdAt).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
: '—';
const jName = typeof p.jurisdiction === 'object' && p.jurisdiction !== null
? p.jurisdiction.label || p.jurisdiction.name || p.jurisdiction.code
: p.jurisdiction || 'GLOBAL';
return {
...p,
id: p.id,
policyName: p.policyName || p.name || 'Untitled Policy',
jurisdiction: jName || 'GLOBAL',
status: formattedStatus,
lastModified: formattedDate,
};
});
return {
data: formattedData,
total: res?.total ?? formattedData.length,
totalPages: res?.totalPages ?? 1,
page: res?.page ?? page,
};
});
}
export function getPolicy(id: string): Promise<PolicyEngineResponse> {
return ApiClient.get<any, PolicyEngineResponse>(`/policy-engine/${id}`);
}
export function createPolicy(payload: any): Promise<PolicyEngineResponse> {
return ApiClient.post<any, PolicyEngineResponse>('/policy-engine', payload);
}
export function updatePolicy(id: string, payload: any): Promise<PolicyEngineResponse> {
return ApiClient.put<any, PolicyEngineResponse>(`/policy-engine/${id}`, payload);
}
export function updatePolicyStatus(id: string, status: string): Promise<PolicyEngineResponse> {
return ApiClient.patch<any, PolicyEngineResponse>(`/policy-engine/${id}/status`, { status: status.toLowerCase() });
}
export function deletePolicy(id: string): Promise<void> {
return ApiClient.delete<any, void>(`/policy-engine/${id}`);
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { PlusIcon, TrashIcon, MagnifyingGlassIcon, XIcon, PencilSimpleIcon, CopyIcon, ChecksIcon } from '@phosphor-icons/react';
import { PlusIcon, TrashIcon, MagnifyingGlassIcon, XIcon, PencilSimpleIcon, ChecksIcon } from '@phosphor-icons/react';
import { useNavigate } from 'react-router-dom';
import {
CustomTable,
@@ -13,45 +13,12 @@ import {
} from '../../../components/custom';
import type { Column } from '../../../components/custom/CustomTable';
import type { PolicyEngineResponse } from '../PolicyEngineTypes';
import { getPolicies, deletePolicy, updatePolicyStatus } from '../PolicyEngineApi';
// ─── Constants ───────────────────────────────────────────────────────────────
const PAGE_SIZE = 10;
// ─── Mock Data ───────────────────────────────────────────────────────────────
const MOCK_POLICIES: PolicyEngineResponse[] = [
{
id: '1',
policyName: 'New Recovery Strategy',
jurisdiction: 'GLOBAL',
status: 'Active',
lastModified: '4 Jun 2026, 4:09pm',
},
{
id: '2',
policyName: 'EU261 Standard Recovery',
jurisdiction: 'GLOBAL',
status: 'Active',
lastModified: '4 Jun 2026, 4:09pm',
},
{
id: '3',
policyName: 'US DOT Consumer Protection',
jurisdiction: 'GLOBAL',
status: 'Active',
lastModified: '4 Jun 2026, 4:09pm',
},
// Add some more mock data to demonstrate pagination if needed
...Array.from({ length: 9 }).map((_, i) => ({
id: `mock-${i + 4}`,
policyName: `Sample Policy ${i + 4}`,
jurisdiction: 'GLOBAL',
status: i % 2 === 0 ? 'Draft' : 'Inactive' as 'Active' | 'Inactive' | 'Draft',
lastModified: '5 Jun 2026, 10:00am',
}))
];
// ─── Helpers ─────────────────────────────────────────────────────────────────
function HeaderLabel({ text }: { text: string }) {
@@ -94,32 +61,29 @@ export default function PolicyEngineList() {
// Modal state
const [deleteTarget, setDeleteTarget] = useState<PolicyEngineResponse | null>(null);
const [deactivateTarget, setDeactivateTarget] = useState<PolicyEngineResponse | null>(null);
// ─── Fetch data ─────────────────────────────────────────────
const fetchPolicies = useCallback((page: number) => {
setLoading(true);
// Simulate API call with timeout
setTimeout(() => {
const filteredData = MOCK_POLICIES.filter(p =>
p.policyName.toLowerCase().includes(search.toLowerCase())
);
const total = filteredData.length;
const pages = Math.ceil(total / PAGE_SIZE);
const start = (page - 1) * PAGE_SIZE;
const paginatedData = filteredData.slice(start, start + PAGE_SIZE);
setPolicies(paginatedData);
setTotalItems(total);
setTotalPages(pages || 1);
setLoading(false);
}, 500);
getPolicies(page, PAGE_SIZE, search)
.then((res) => {
setPolicies(res.data || []);
setTotalItems(res.total || 0);
setTotalPages(res.totalPages || 1);
})
.catch((err) => {
console.error('Failed to fetch policies:', err);
setPolicies([]);
setTotalItems(0);
setTotalPages(1);
})
.finally(() => {
setLoading(false);
});
}, [search]);
useEffect(() => {
// Trigger a new fetch when page or search changes
fetchPolicies(currentPage);
}, [currentPage, search, fetchPolicies]);
@@ -136,18 +100,25 @@ export default function PolicyEngineList() {
const handleDelete = async () => {
if (!deleteTarget) return;
// Simulate delete
console.log('Deleted policy:', deleteTarget.id);
setDeleteTarget(null);
fetchPolicies(currentPage);
try {
await deletePolicy(deleteTarget.id);
setDeleteTarget(null);
fetchPolicies(currentPage);
} catch (err) {
console.error('Failed to delete policy:', err);
}
};
const handleToggleStatus = async () => {
if (!deactivateTarget) return;
// Simulate toggle status
console.log('Toggled status for policy:', deactivateTarget.id);
setDeactivateTarget(null);
fetchPolicies(currentPage);
try {
const nextStatus = deactivateTarget.status === 'Active' ? 'inactive' : 'active';
await updatePolicyStatus(deactivateTarget.id, nextStatus);
setDeactivateTarget(null);
fetchPolicies(currentPage);
} catch (err) {
console.error('Failed to toggle policy status:', err);
}
};
const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
@@ -198,15 +169,9 @@ export default function PolicyEngineList() {
Deactivate
</CustomActionItem>
)}
<CustomActionItem
icon={<CopyIcon size={15} />}
onClick={() => console.log('Duplicate:', row.id)}
>
Duplicate
</CustomActionItem>
<CustomActionItem
icon={<PencilSimpleIcon size={15} />}
onClick={() => console.log('Edit:', row.id)}
onClick={() => navigate(`/policy-engine/add?id=${row.id}`)}
>
Edit
</CustomActionItem>
+1 -1
View File
@@ -15,7 +15,7 @@ interface CustomButtonProps
const CustomButton: React.FC<CustomButtonProps> = ({
variant = "primary",
size = "md",
size = "sm",
leftIcon,
rightIcon,
loading = false,
+86 -40
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from "react";
import { CaretDownIcon, CheckIcon } from "@phosphor-icons/react";
import { CaretDownIcon, CheckIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
import DropdownPortal from "./DropdownPortal";
interface Option {
@@ -20,6 +20,8 @@ interface CustomDropdownProps {
required?: boolean;
className?: string;
error?: string;
searchable?: boolean;
searchPlaceholder?: string;
}
const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
@@ -36,12 +38,16 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
leftIcon,
error,
size = "md",
searchable = true,
searchPlaceholder = "Search...",
},
ref
) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const dropdownRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const sizeClasses = {
sm: "py-1.5 text-sm h-[36px]",
@@ -58,6 +64,7 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
!(panelRef.current && panelRef.current.contains(target))
) {
setIsOpen(false);
setSearchQuery("");
}
};
@@ -65,16 +72,34 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
if (isOpen && searchable) {
const timer = setTimeout(() => {
searchInputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
} else if (!isOpen) {
setSearchQuery("");
}
}, [isOpen, searchable]);
const handleSelect = (option: Option) => {
if (option.disabled) return;
onChange?.(String(option.value));
setIsOpen(false);
setSearchQuery("");
};
const selectedOption = value !== '' && value !== null && value !== undefined
? options.find((opt) => String(opt.value) === String(value))
: undefined;
const filteredOptions = searchable && searchQuery.trim()
? options.filter((opt) =>
opt.label.toLowerCase().includes(searchQuery.toLowerCase().trim())
)
: options;
return (
<div className="w-full flex flex-col gap-1.5" ref={ref}>
{label && (
@@ -130,47 +155,68 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
anchorRef={dropdownRef}
isOpen={isOpen && !disabled}
ref={panelRef}
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-y-auto flex flex-col"
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden flex flex-col max-h-[300px]"
>
{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 = String(option.value) === String(value);
return (
<button
key={option.value}
type="button"
disabled={option.disabled}
onClick={(e) => {
e.stopPropagation();
handleSelect(option);
}}
className={`
w-full text-left px-3 min-h-[42px] text-[14px] leading-none
transition-colors duration-150 flex items-center gap-2
${isSelected
? "bg-[#EEF9EF]"
: option.disabled
? "text-gray-400 cursor-not-allowed"
: "text-gray-700 hover:bg-gray-50"
}
`}
>
{isSelected && <CheckIcon size={16} className="text-primary shrink-0" strokeWidth={2.5} />}
<span
className={
isSelected
? "ml-1 font-medium bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent"
: "ml-6"
}
>
{option.label}
</span>
</button>
);
})
{searchable && (
<div className="p-2 border-b border-gray-100 bg-white sticky top-0 z-10">
<div className="relative flex items-center">
<MagnifyingGlassIcon size={16} className="absolute left-2.5 text-gray-400 pointer-events-none" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder}
className="w-full text-xs py-1.5 pl-8 pr-3 border border-gray-200 rounded-md focus:outline-none focus:border-primary text-gray-800 bg-gray-50/50"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
)}
<div className="overflow-y-auto flex-1 max-h-[240px]">
{filteredOptions.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500 text-center">
{searchQuery ? "No matching options" : "No options available"}
</div>
) : (
filteredOptions.map((option) => {
const isSelected = String(option.value) === String(value);
return (
<button
key={option.value}
type="button"
disabled={option.disabled}
onClick={(e) => {
e.stopPropagation();
handleSelect(option);
}}
className={`
w-full text-left px-3 min-h-[42px] text-[14px] leading-none
transition-colors duration-150 flex items-center gap-2
${isSelected
? "bg-[#EEF9EF]"
: option.disabled
? "text-gray-400 cursor-not-allowed"
: "text-gray-700 hover:bg-gray-50"
}
`}
>
{isSelected && <CheckIcon size={16} className="text-primary shrink-0" strokeWidth={2.5} />}
<span
className={
isSelected
? "ml-1 font-medium bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent"
: "ml-6"
}
>
{option.label}
</span>
</button>
);
})
)}
</div>
</DropdownPortal>
</div>
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
+29 -11
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from "react";
import { CaretDownIcon, CheckIcon } from "@phosphor-icons/react";
import { CaretDownIcon, CheckIcon, XCircleIcon } from "@phosphor-icons/react";
import DropdownPortal from "./DropdownPortal";
interface Option {
@@ -44,9 +44,9 @@ const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProp
const panelRef = useRef<HTMLDivElement>(null);
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]",
sm: "h-[36px] text-sm",
md: "h-[44px] text-sm",
lg: "h-[48px] text-base",
};
useEffect(() => {
@@ -78,6 +78,13 @@ const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProp
onChange?.(newValue);
};
const handleRemove = (e: React.MouseEvent, optionValue: string | number) => {
e.stopPropagation();
const optionValueStr = String(optionValue);
const newValue = value.filter(v => String(v) !== optionValueStr);
onChange?.(newValue);
};
const selectedOptions = options.filter(opt => value.some(v => String(v) === String(opt.value)));
return (
@@ -100,11 +107,11 @@ const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProp
px-3
outline-none
transition-all duration-200
flex items-center flex-wrap gap-1
flex items-center justify-between
${!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
pr-9
${className}
`}
>
@@ -114,15 +121,26 @@ const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProp
</span>
)}
<div className="flex-1 text-left text-[14px] font-medium tracking-[0.25px] leading-[15px] flex flex-wrap gap-1 items-center">
<div className="flex-1 text-left font-medium tracking-[0.25px] flex items-center gap-1.5 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden py-0.5">
{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>
<span
key={opt.value}
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-[#F2F9F6] border border-[#D5E8DF] text-[#032D20] text-xs font-medium shrink-0 transition-all"
>
<span>{opt.label}</span>
<button
type="button"
onClick={(e) => handleRemove(e, opt.value)}
className="inline-flex items-center justify-center text-[#7A998C] hover:text-red-500 transition-colors cursor-pointer shrink-0"
title={`Remove ${opt.label}`}
>
<XCircleIcon size={14} weight="fill" />
</button>
</span>
))
) : (
<span style={{ color: '#6C766D' }}>{placeholder}</span>
<span className="text-[#6C766D] truncate">{placeholder}</span>
)}
</div>
+3 -1
View File
@@ -8,6 +8,7 @@ interface CustomSuccessModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
label?: string;
cohortName?: string;
cohortStatus?: string;
cohortDescription?: string;
@@ -17,6 +18,7 @@ const CustomSuccessModal: React.FC<CustomSuccessModalProps> = ({
isOpen,
onClose,
title = "Cohort Created Successfully.",
label = "COHORT NAME",
cohortName,
cohortStatus,
cohortDescription,
@@ -76,7 +78,7 @@ const CustomSuccessModal: React.FC<CustomSuccessModalProps> = ({
<div className="flex justify-between items-start mb-4">
<div>
<p className="text-[10px] font-bold tracking-wider text-[#1B9869] uppercase mb-1">
COHORT NAME
{label}
</p>
<p className="text-base font-bold text-[#152A3C]">
{cohortName}