feat: implement policy engine, action builder, and master data management modules with shared custom UI components
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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 [];
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { MasterDataHeader } from './MasterDataHeader';
|
||||
export { CategorySidebar } from './CategorySidebar';
|
||||
export { MasterItemTable } from './MasterItemTable';
|
||||
export { MasterItemFormModal } from './MasterItemFormModal';
|
||||
+48
-327
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,7 +15,7 @@ interface CustomButtonProps
|
||||
|
||||
const CustomButton: React.FC<CustomButtonProps> = ({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
size = "sm",
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
loading = false,
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user