feat: implement Policy Engine and Master Data API services along with associated components

This commit is contained in:
Syed Waseem
2026-08-11 12:58:52 +05:30
parent 26a3f68b99
commit 8ac4376a25
7 changed files with 272 additions and 108 deletions
@@ -99,7 +99,7 @@ export function submitActionPayload(payload: ActionSubmissionPayload): Promise<a
// ─── Master Data Options Loader ──────────────────────────────────────────────
export function getMasterDataOptions(categoryCode: string): Promise<{ label: string; value: string; id?: string }[]> {
return ApiClient.get<any, any[]>(`/master-data/category-values/${categoryCode}`)
return ApiClient.get<any, any[]>(`/master-data/${categoryCode}`)
.then((res) => {
if (Array.isArray(res)) {
return res.map((item) => {
@@ -49,7 +49,6 @@ export default function ActionBuilderPage() {
const [masterLookupOptions, setMasterLookupOptions] = useState<{ label: string; value: string }[]>([
{ label: 'None (Manual / Free-text)', value: '' },
{ label: 'Currencies (CURRENCIES)', value: 'CURRENCIES' },
]);
// Loading & Notification States
@@ -162,7 +161,6 @@ export default function ActionBuilderPage() {
}));
setMasterLookupOptions([
{ label: 'None (Manual / Free-text)', value: '' },
{ label: 'Currencies (CURRENCIES)', value: 'CURRENCIES' },
...opts,
]);
}
@@ -21,11 +21,22 @@ function normalizeItems(items: any[]): MasterDataItem[] {
});
}
// ─── Categories List Endpoint ───────────────────────────────────────────────
// ─── Lookup Tables & Categories List Endpoint ─────────────────────────────────
export function getLookupTables(): Promise<MasterDataCategoryItem[]> {
return ApiClient.get<any, MasterDataCategoryItem[]>('/master-data/lookup-tables')
.then((res) => (Array.isArray(res) ? res : []))
.catch(() => []);
}
export function getMasterCategories(): Promise<MasterDataCategoryItem[]> {
return ApiClient.get<any, MasterDataCategoryItem[]>('/master-data/categories')
.then((res) => (Array.isArray(res) ? res : []))
return getLookupTables()
.then((tables) => {
if (Array.isArray(tables) && tables.length > 0) return tables;
return ApiClient.get<any, MasterDataCategoryItem[]>('/master-data/categories')
.then((res) => (Array.isArray(res) ? res : []))
.catch(() => []);
})
.catch(() => []);
}
@@ -33,13 +44,9 @@ export function getMasterCategories(): Promise<MasterDataCategoryItem[]> {
export function getCategoryValues(categoryCode: string): Promise<MasterDataItem[]> {
if (!categoryCode) return Promise.resolve([]);
return ApiClient.get<any, any[]>(`/master-data/category-values/${categoryCode}`)
return ApiClient.get<any, any[]>(`/master-data/${categoryCode}`)
.then((res) => normalizeItems(res))
.catch(() =>
ApiClient.get<any, any[]>(`/master-data/${categoryCode}`)
.then((res) => normalizeItems(res))
.catch(() => []),
);
.catch(() => []);
}
// ─── Generic Master Data Item CRUD ──────────────────────────────────────────
@@ -121,11 +121,11 @@ export const MasterItemTable: React.FC<MasterItemTableProps> = ({
</td>
<td className="py-3 px-4 font-bold text-[#0F172B]">
{item.value || item.label || item.name}
{item.label || item.value}
</td>
<td className="py-3 px-4 font-mono text-slate-500 text-[12px]">
{item.code || '—'}
{item.value || '—'}
</td>
<td className="py-3 px-4">
@@ -182,11 +182,10 @@ export const MasterItemTable: React.FC<MasterItemTableProps> = ({
<button
key={page}
onClick={() => onPageChange(page)}
className={`min-w-[32px] h-8 px-2 rounded-lg text-[13px] font-semibold transition-colors ${
currentPage === page
? 'bg-[#1E7D5C] text-white shadow-sm font-bold'
: 'bg-white text-slate-600 border border-gray-200 hover:bg-slate-50'
}`}
className={`min-w-[32px] h-8 px-2 rounded-lg text-[13px] font-semibold transition-colors ${currentPage === page
? 'bg-[#1E7D5C] text-white shadow-sm font-bold'
: 'bg-white text-slate-600 border border-gray-200 hover:bg-slate-50'
}`}
>
{page}
</button>
+40 -12
View File
@@ -45,13 +45,9 @@ function normalizeItems(items: any[]): any[] {
function fetchCategoryValues(categoryCodeOrId: string): Promise<any[]> {
if (!categoryCodeOrId) return Promise.resolve([]);
return ApiClient.get<any, any[]>(`/master-data/category-values/${categoryCodeOrId}`)
return ApiClient.get<any, any[]>(`/master-data/${categoryCodeOrId}`)
.then((res) => normalizeItems(res))
.catch(() =>
ApiClient.get<any, any[]>(`/master-data/${categoryCodeOrId}`)
.then((res) => normalizeItems(res))
.catch(() => []),
);
.catch(() => []);
}
function fetchMastersCategories(): Promise<any[]> {
@@ -187,17 +183,18 @@ export function getConditionOptions(categoryCodeOrId?: string): Promise<OptionIt
}
export function getOperatorOptions(): Promise<OptionItem[]> {
return fetchCategoryValues('operator')
.then((items) =>
(items || [])
return ApiClient.get<any, any[]>('/master-data/operators')
.then((res) => {
const items = Array.isArray(res) ? res : [];
return items
.filter((i) => i.isActive !== false)
.map((i) => ({
label: i.label || i.name || i.value || '',
label: i.name || i.symbol || i.label || i.code || '',
value: i.id || i.code || i.value || '',
id: i.id,
code: i.code,
})),
)
}));
})
.catch(() => []);
}
@@ -294,3 +291,34 @@ export function updatePolicyStatus(id: string, status: string): Promise<PolicyEn
export function deletePolicy(id: string): Promise<void> {
return ApiClient.delete<any, void>(`/policy-engine/${id}`);
}
// ─── 3-Level Metadata API Functions ──────────────────────────────────────────
export function getConditionGroupsForCategory(categoryCodeOrId: string): Promise<any[]> {
if (!categoryCodeOrId) return Promise.resolve([]);
return ApiClient.get<any, any[]>(`/master-data/rule-categories/${categoryCodeOrId}/condition-groups`)
.then((res) => (Array.isArray(res) ? res : []))
.catch(() => []);
}
export function getConditionFieldsForGroup(groupIdOrCode: string): Promise<any[]> {
if (!groupIdOrCode) return Promise.resolve([]);
return ApiClient.get<any, any[]>(`/master-data/condition-groups/${groupIdOrCode}/fields`)
.then((res) => (Array.isArray(res) ? res : []))
.catch(() => []);
}
export function getFieldLookupOptions(fieldIdOrCode: string): Promise<OptionItem[]> {
if (!fieldIdOrCode) return Promise.resolve([]);
return ApiClient.get<any, any[]>(`/master-data/condition-fields/${fieldIdOrCode}/lookup-values`)
.then((res) =>
(res || []).map((i) => ({
label: i.label || i.name || i.value || '',
value: i.id || i.code || i.value || '',
id: i.id,
code: i.code,
})),
)
.catch(() => []);
}
+28
View File
@@ -12,3 +12,31 @@ export interface PaginatedPolicyEngineResponse {
totalPages: number;
page: number;
}
export interface ConditionGroupItem {
id: string;
code: string;
name: string;
displayOrder?: number;
isActive?: boolean;
}
export interface ConditionFieldItem {
id: string;
groupId: string;
code: string;
name: string;
lookupTable?: string;
dataType: 'STRING' | 'NUMBER' | 'BOOLEAN' | 'ENUM' | 'DATE';
operatorType: 'COMPARISON' | 'TEXT' | 'SET' | 'BOOLEAN';
displayOrder?: number;
isActive?: boolean;
}
export interface LookupOptionItem {
id: string;
code: string;
label: string;
value: string;
displayOrder?: number;
}
@@ -30,6 +30,9 @@ import {
getPolicy,
createPolicy,
updatePolicy,
getConditionGroupsForCategory,
getConditionFieldsForGroup,
getFieldLookupOptions,
type OptionItem,
type ActionTypeField,
} from '../PolicyEngineApi';
@@ -75,7 +78,6 @@ type Rule = {
const LOGIC_OPTIONS = [
{ label: 'AND', value: 'AND' },
{ label: 'OR', value: 'OR' },
{ label: 'NOT', value: 'NOT' },
];
// Per-gate color scheme (exact Figma tokens): AND=blue, OR=yellow, NOT=red.
@@ -175,15 +177,76 @@ export default function AddPolicyEngine() {
getActionCategoryOptions().then(setActionCategoryOptions);
}, []);
const handleCategoryChange = (ruleId: string, category: string) => {
handleUpdateRule(ruleId, { category });
if (category && !categoryConditionMap[category]) {
getConditionOptions(category).then((opts) => {
const [conditionFieldsMetaMap, setConditionFieldsMetaMap] = useState<Record<string, any>>({});
const [conditionValueLookupMap, setConditionValueLookupMap] = useState<Record<string, OptionItem[]>>({});
const loadCategoryConditions = async (category: string) => {
if (!category) return [];
try {
const groups = await getConditionGroupsForCategory(category);
const allFields: OptionItem[] = [];
const metaMap: Record<string, any> = {};
for (const g of groups) {
const fields = await getConditionFieldsForGroup(g.id || g.code);
fields.forEach((f: any) => {
const val = f.id || f.code;
allFields.push({
label: `${g.name} ${f.name}`,
value: val,
id: f.id,
code: f.code,
});
metaMap[val] = f;
});
}
setCategoryConditionMap((prev) => ({
...prev,
[category]: allFields,
}));
setConditionFieldsMetaMap((prev) => ({
...prev,
...metaMap,
}));
return allFields;
} catch (err) {
console.error('Failed to load condition groups/fields for category', err);
try {
const fallbackOpts = await getConditionOptions(category);
setCategoryConditionMap((prev) => ({
...prev,
[category]: opts,
[category]: fallbackOpts,
}));
});
return fallbackOpts;
} catch {
return [];
}
}
};
const handleCategoryChange = async (ruleId: string, category: string) => {
handleUpdateRule(ruleId, { category });
if (category) {
await loadCategoryConditions(category);
}
};
const handleConditionFieldSelect = async (ruleId: string, conditionId: string, selectedFieldIdOrCode: string) => {
handleUpdateCondition(ruleId, conditionId, { condition: selectedFieldIdOrCode, value: '' });
if (selectedFieldIdOrCode) {
try {
const opts = await getFieldLookupOptions(selectedFieldIdOrCode);
if (opts && opts.length > 0) {
setConditionValueLookupMap((prev) => ({
...prev,
[selectedFieldIdOrCode]: opts,
}));
}
} catch (err) {
console.error('Failed to load lookup options for condition field', err);
}
}
};
@@ -324,22 +387,33 @@ export default function AddPolicyEngine() {
const cat = matchedRuleCat ? matchedRuleCat.value : rawCat;
if (cat) {
getConditionOptions(cat).then((opts) => {
setCategoryConditionMap((prev) => ({ ...prev, [cat]: opts }));
});
await loadCategoryConditions(cat);
}
const loadedConditions: Condition[] =
Array.isArray(r.conditions) && r.conditions.length > 0
? r.conditions.map((c: any) => ({
id: c.id || crypto.randomUUID(),
condition: c.fieldId || c.condition || '',
operator: c.operatorId || c.operator || '',
value: c.valueText || c.value || '',
logic: c.logicalOperator || c.logic || 'AND',
}))
id: c.id || crypto.randomUUID(),
condition: c.fieldId || c.condition || '',
operator: c.operatorId || c.operator || '',
value: c.valueText || c.value || '',
logic: c.logicalOperator || c.logic || 'AND',
}))
: [{ id: crypto.randomUUID(), condition: '', operator: '', value: '', logic: 'AND' }];
for (const c of loadedConditions) {
if (c.condition) {
try {
const opts = await getFieldLookupOptions(c.condition);
if (opts && opts.length > 0) {
setConditionValueLookupMap((prev) => ({ ...prev, [c.condition]: opts }));
}
} catch (err) {
console.error('Failed to load lookup options for condition', err);
}
}
}
const loadedActions: Action[] = [];
if (Array.isArray(r.actions) && r.actions.length > 0) {
for (const a of r.actions) {
@@ -454,9 +528,9 @@ export default function AddPolicyEngine() {
targetAudiences:
audienceType === 'Selected Cohorts'
? selectedCohorts.map((cohortId) => ({
targetType: 'COHORT',
targetId: String(cohortId),
}))
targetType: 'COHORT',
targetId: String(cohortId),
}))
: [],
rules: rules.map((r, rIdx) => ({
ruleCategoryId:
@@ -862,69 +936,99 @@ export default function AddPolicyEngine() {
<div className="mb-8">
<h4 className="text-[14px] font-bold text-[#0F172B] mb-4">Visual Condition Builder</h4>
<div className="flex flex-col gap-3">
{rule.conditions.map((condition, cIdx) => (
<div key={condition.id} className="flex items-end gap-3">
<div className="flex-1 flex flex-col gap-2">
<CustomDropdown
label='Condition'
options={categoryConditionMap[rule.category] || []}
value={condition.condition}
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { condition: val })}
placeholder={rule.category ? "Select Condition..." : "Select Rule Category first"}
disabled={!rule.category}
/>
</div>
<div className="flex-1 flex flex-col gap-2">
<CustomDropdown
label='Operator'
options={operatorOptions}
value={condition.operator}
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { operator: val })}
placeholder="Selected Option"
/>
</div>
<div className="flex-1 flex flex-col gap-2">
<CustomInput
label='Value'
value={condition.value}
onChange={(e) => handleUpdateCondition(rule.id, condition.id, { value: e.target.value })}
placeholder="Selected Option"
/>
</div>
{cIdx < rule.conditions.length - 1 ? (
<div className="w-[163px] flex flex-col gap-2">
<label className="text-[14px] font-medium leading-none text-[#001811]">Logic</label>
<LogicDropdown
value={condition.logic}
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { logic: val })}
{rule.conditions.map((condition, cIdx) => {
const fieldMeta = conditionFieldsMetaMap[condition.condition];
const lookupOpts = conditionValueLookupMap[condition.condition] || [];
const isLookup = (fieldMeta && (fieldMeta.lookupTable || fieldMeta.dataType === 'ENUM')) || lookupOpts.length > 0;
const isBoolean = fieldMeta?.dataType === 'BOOLEAN';
const isNumber = fieldMeta?.dataType === 'NUMBER';
return (
<div key={condition.id} className="flex items-end gap-3">
<div className="flex-1 flex flex-col gap-2">
<CustomDropdown
label='Condition'
options={categoryConditionMap[rule.category] || []}
value={condition.condition}
onChange={(val) => handleConditionFieldSelect(rule.id, condition.id, val)}
placeholder={rule.category ? "Select Condition..." : "Select Rule Category first"}
disabled={!rule.category}
/>
</div>
) : (
<div className="w-[163px] flex items-center">
<CustomButton
variant="secondary"
className="!w-full !justify-center !bg-[#EAFAF5] hover:!bg-[#d9ece4] !px-4 !gap-2.5 !h-[49px] !rounded-[12px] whitespace-nowrap"
leftIcon={<PlusIcon size={16} color="#14704E" />}
onClick={() => handleAddCondition(rule.id)}
>
<span className="font-bold text-[14px] leading-none text-center bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent">
Add Condition
</span>
</CustomButton>
<div className="flex-1 flex flex-col gap-2">
<CustomDropdown
label='Operator'
options={operatorOptions}
value={condition.operator}
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { operator: val })}
placeholder="Selected Option"
/>
</div>
)}
<div className="flex items-center h-11">
{rule.conditions.length > 1 && (
<button
onClick={() => handleDeleteCondition(rule.id, condition.id)}
className="w-11 h-11 flex items-center justify-center bg-red-50 text-[#D40000] rounded-[10px] hover:bg-red-100 transition-colors"
>
<TrashIcon size={16} strokeWidth={1.5} color="#D40000" />
</button>
<div className="flex-1 flex flex-col gap-2">
{isLookup ? (
<CustomDropdown
label='Value'
options={lookupOpts}
value={condition.value}
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { value: val })}
placeholder="Select Value..."
/>
) : isBoolean ? (
<CustomDropdown
label='Value'
options={[
{ label: 'True', value: 'true' },
{ label: 'False', value: 'false' },
]}
value={condition.value}
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { value: val })}
placeholder="Select..."
/>
) : (
<CustomInput
label='Value'
type={isNumber ? 'number' : 'text'}
value={condition.value}
onChange={(e) => handleUpdateCondition(rule.id, condition.id, { value: e.target.value })}
placeholder="Enter Value..."
/>
)}
</div>
{cIdx < rule.conditions.length - 1 ? (
<div className="w-[163px] flex flex-col gap-2">
<label className="text-[14px] font-medium leading-none text-[#001811]">Logic</label>
<LogicDropdown
value={condition.logic}
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { logic: val })}
/>
</div>
) : (
<div className="w-[163px] flex items-center">
<CustomButton
variant="secondary"
className="!w-full !justify-center !bg-[#EAFAF5] hover:!bg-[#d9ece4] !px-4 !gap-2.5 !h-[49px] !rounded-[12px] whitespace-nowrap"
leftIcon={<PlusIcon size={16} color="#14704E" />}
onClick={() => handleAddCondition(rule.id)}
>
<span className="font-bold text-[14px] leading-none text-center bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent">
Add Condition
</span>
</CustomButton>
</div>
)}
<div className="flex items-center h-11">
{rule.conditions.length > 1 && (
<button
onClick={() => handleDeleteCondition(rule.id, condition.id)}
className="w-11 h-11 flex items-center justify-center bg-red-50 text-[#D40000] rounded-[10px] hover:bg-red-100 transition-colors"
>
<TrashIcon size={16} strokeWidth={1.5} color="#D40000" />
</button>
)}
</div>
</div>
</div>
))}
)
})}
</div>
</div>